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::{TaskItemStatus, TaskList};
40use chrono::{DateTime, Utc};
41use serde::{Deserialize, Serialize};
42
43/// Represents events emitted during agent execution.
44///
45/// These events are streamed to clients via SSE to provide real-time
46/// feedback on agent progress, tool execution, and completion.
47///
48/// # Variants
49///
50/// ## Text Generation
51/// - `Token` - Streaming text token
52/// - `ReasoningToken` - Streaming reasoning/thinking token (separate channel)
53///
54/// ## Tool Execution
55/// - `ToolStart` - Tool execution started
56/// - `ToolComplete` - Tool finished successfully
57/// - `ToolError` - Tool execution failed
58///
59/// ## User Interaction
60/// - `NeedClarification` - Agent needs user input
61///
62/// ## Progress Tracking
63/// - `TaskListUpdated` - Task list created or modified
64/// - `TaskListItemProgress` - Individual item progress
65/// - `TaskListCompleted` - All items completed
66/// - `TaskEvaluationStarted` - Task evaluation began
67/// - `TaskEvaluationCompleted` - Task evaluation finished
68/// - `GoldEvaluationStarted` - Gold observe-only evaluation began
69/// - `GoldEvaluationCompleted` - Gold observe-only evaluation finished
70///
71/// ## Context Management
72/// - `TokenBudgetUpdated` - Context budget changed
73/// - `ContextCompressionStatus` - Context compression lifecycle progress
74/// - `ContextSummarized` - Old messages summarized
75///
76/// ## Sub-agents (Async Spawn)
77/// - `SubAgentStarted` - A child session is created and scheduled to run
78/// - `SubAgentEvent` - Forwarded raw child event (full fidelity)
79/// - `SubAgentHeartbeat` - Periodic heartbeat while the child is running
80/// - `SubAgentCompleted` - Child session finished (completed/cancelled/error)
81///
82/// ## Terminal Events
83/// - `Complete` - Execution finished successfully
84/// - `Cancelled` - Execution was cancelled by the user
85/// - `Error` - Execution failed
86///
87/// # Serialization
88///
89/// Events are serialized as JSON with a `type` field for discrimination:
90/// ```json
91/// {"type": "token", "content": "Hello"}
92/// {"type": "complete", "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}}
93/// {"type": "cancelled", "message": "Agent execution cancelled by user"}
94/// ```
95#[derive(Debug, Clone, Serialize, Deserialize)]
96#[serde(tag = "type", rename_all = "snake_case")]
97pub enum AgentEvent {
98 /// Text token generated by the LLM.
99 Token {
100 /// Generated text content
101 content: String,
102 },
103
104 /// Reasoning/thinking token generated by the LLM.
105 ///
106 /// This is streamed separately from assistant answer tokens so the UI can
107 /// choose whether and how to display model reasoning traces.
108 ReasoningToken {
109 /// Generated reasoning content
110 content: String,
111 },
112
113 /// Streaming output emitted while a specific tool call is running.
114 ///
115 /// This is used to render "live output" inside a tool-call card in the UI
116 /// without mixing tool output into the assistant's main token stream.
117 ToolToken {
118 /// Tool call identifier that this output belongs to.
119 tool_call_id: String,
120 /// Output chunk.
121 content: String,
122 },
123
124 /// Tool execution started.
125 ToolStart {
126 /// Unique tool call identifier
127 tool_call_id: String,
128 /// Name of the tool being executed
129 tool_name: String,
130 /// Tool arguments (JSON)
131 arguments: serde_json::Value,
132 },
133
134 /// Tool execution completed successfully.
135 ToolComplete {
136 /// Tool call identifier
137 tool_call_id: String,
138 /// Tool execution result
139 result: ToolResult,
140 },
141
142 /// Tool execution failed.
143 ToolError {
144 /// Tool call identifier
145 tool_call_id: String,
146 /// Error message
147 error: String,
148 },
149
150 /// Structured lifecycle event for tool execution tracking.
151 ///
152 /// These events complement `ToolStart`/`ToolComplete`/`ToolError` with
153 /// richer metadata (mutability, auto-approval, wall-clock timing) and
154 /// are emitted by `ToolEmitter` (in `bamboo-agent-tools`).
155 ToolLifecycle {
156 /// Tool call identifier
157 tool_call_id: String,
158 /// Canonical tool name
159 tool_name: String,
160 /// Lifecycle phase: "begin", "finished", "error", "cancelled"
161 phase: String,
162 /// Wall-clock milliseconds since the call began (None for begin)
163 #[serde(skip_serializing_if = "Option::is_none")]
164 elapsed_ms: Option<u64>,
165 /// Whether the tool mutates state (writes files, runs commands)
166 is_mutating: bool,
167 /// Whether execution was auto-approved (no user prompt needed)
168 auto_approved: bool,
169 /// Human-readable summary
170 #[serde(skip_serializing_if = "Option::is_none")]
171 summary: Option<String>,
172 /// Error message (if phase == "error")
173 #[serde(skip_serializing_if = "Option::is_none")]
174 error: Option<String>,
175 },
176
177 /// Agent needs clarification from the user.
178 NeedClarification {
179 /// Question to ask the user
180 question: String,
181 /// Optional predefined options
182 options: Option<Vec<String>>,
183 /// Tool call identifier that triggered this clarification
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 tool_call_id: Option<String>,
186 /// Tool name that triggered this clarification, when known.
187 #[serde(default, skip_serializing_if = "Option::is_none")]
188 tool_name: Option<String>,
189 /// Whether the user can provide a free-text response
190 #[serde(default = "default_allow_custom")]
191 allow_custom: bool,
192 },
193
194 /// Emitted when task list is created or updated.
195 TaskListUpdated {
196 /// Current task list state.
197 task_list: TaskList,
198 },
199
200 /// Emitted when a task item makes progress (delta update).
201 TaskListItemProgress {
202 /// Session identifier
203 session_id: String,
204 /// Item identifier
205 item_id: String,
206 /// New item status
207 status: TaskItemStatus,
208 /// Number of tool calls made
209 tool_calls_count: usize,
210 /// Item version (for optimistic concurrency)
211 version: u64,
212 },
213
214 /// Emitted when all task items are completed.
215 TaskListCompleted {
216 /// Session identifier
217 session_id: String,
218 /// Completion timestamp
219 completed_at: DateTime<Utc>,
220 /// Total agent rounds executed
221 total_rounds: u32,
222 /// Total tool calls made
223 total_tool_calls: usize,
224 },
225
226 /// Emitted when task evaluation starts.
227 TaskEvaluationStarted {
228 /// Session identifier
229 session_id: String,
230 /// Number of items to evaluate
231 items_count: usize,
232 },
233
234 /// Emitted when task evaluation completes.
235 TaskEvaluationCompleted {
236 /// Session identifier
237 session_id: String,
238 /// Number of items updated
239 updates_count: usize,
240 /// Evaluation reasoning
241 reasoning: String,
242 },
243
244 /// Emitted when gold observe-only evaluation starts.
245 GoldEvaluationStarted {
246 /// Session identifier
247 session_id: String,
248 /// Evaluation checkpoint
249 checkpoint: GoldCheckpoint,
250 /// Current iteration / round number associated with the evaluation
251 iteration: u32,
252 },
253
254 /// Emitted when gold observe-only evaluation completes.
255 GoldEvaluationCompleted {
256 /// Session identifier
257 session_id: String,
258 /// Evaluation checkpoint
259 checkpoint: GoldCheckpoint,
260 /// Current iteration / round number associated with the evaluation
261 iteration: u32,
262 /// Gold decision for the current checkpoint
263 decision: GoldDecision,
264 /// Confidence in the decision
265 confidence: GoldConfidence,
266 /// Short reasoning summary
267 reasoning: String,
268 },
269
270 /// Emitted whenever the runtime goal state changes — a new status
271 /// (active/complete/blocked/…), an incremented continuation count, or a
272 /// freshly recorded side-channel double-check verdict. Lets the UI reflect
273 /// live goal progress without re-fetching history. Ephemeral: it rides only
274 /// the per-session `/events/{id}` stream; reconnecting clients read the
275 /// authoritative `goal_state` from the history endpoint instead.
276 GoalStatusChanged {
277 /// Session identifier
278 session_id: String,
279 /// Full serialized goal state — identical shape to the history
280 /// response's `goal_state` field (see `bamboo_engine::runtime::goal_state`).
281 goal_state: serde_json::Value,
282 },
283
284 /// Emitted when token budget is prepared (after context truncation)
285 TokenBudgetUpdated {
286 /// Token budget details
287 usage: TokenBudgetUsage,
288 },
289
290 /// Emitted when host-side context compression lifecycle changes.
291 ContextCompressionStatus {
292 /// Compression phase label (for example: pre-turn, mid-turn).
293 phase: String,
294 /// Compression status: started | completed | failed | skipped
295 status: String,
296 },
297
298 /// Emitted when conversation context is summarized
299 ContextSummarized {
300 /// Generated summary text
301 summary: String,
302 /// Number of old messages summarized
303 messages_summarized: usize,
304 /// Tokens saved by summarization
305 tokens_saved: u32,
306 /// Context usage percentage before compression
307 #[serde(default)]
308 usage_before_percent: f64,
309 /// Context usage percentage after compression
310 #[serde(default)]
311 usage_after_percent: f64,
312 /// What triggered the compression: "auto" | "manual" | "critical"
313 #[serde(default)]
314 trigger_type: String,
315 },
316
317 /// Emitted when context pressure reaches warning or critical levels.
318 /// Frontend should display this to the user as a proactive notification.
319 ContextPressureNotification {
320 /// Context usage as a percentage of the context window.
321 percent: f64,
322 /// Severity level: "warning" (70%) or "critical" (90%).
323 level: String,
324 /// Human-readable message describing the pressure state.
325 message: String,
326 },
327
328 /// A child session was spawned from a parent session (async background job).
329 SubAgentStarted {
330 parent_session_id: String,
331 child_session_id: String,
332 /// Optional title (useful for UI lists).
333 #[serde(default, skip_serializing_if = "Option::is_none")]
334 title: Option<String>,
335 },
336
337 /// Forwarded raw child event to the parent session stream.
338 ///
339 /// Child sessions are not allowed to spawn further sessions, so this should not nest.
340 SubAgentEvent {
341 parent_session_id: String,
342 child_session_id: String,
343 event: Box<AgentEvent>,
344 },
345
346 /// Heartbeat emitted while a child session is running.
347 SubAgentHeartbeat {
348 parent_session_id: String,
349 child_session_id: String,
350 timestamp: DateTime<Utc>,
351 },
352
353 /// Child session finished (completed/cancelled/error).
354 SubAgentCompleted {
355 parent_session_id: String,
356 child_session_id: String,
357 /// One of: "completed" | "cancelled" | "error" | "skipped"
358 status: String,
359 #[serde(default, skip_serializing_if = "Option::is_none")]
360 error: Option<String>,
361 },
362
363 /// Background Bash shell finished (completed/killed/error).
364 ///
365 /// Emitted by the background shell runtime when a `run_in_background`
366 /// command exits, so clients can react to (and, in later phases, resume
367 /// around) long-running commands. Phase 1 (issue #84): completion signal
368 /// only — this does not change the default foreground behavior.
369 ///
370 /// Delivery scope: a *live* signal. It rides the per-session
371 /// `/events/{id}` stream and the in-memory late-subscriber replay cache
372 /// (`is_critical_event`), but is intentionally **not** a durable change in
373 /// Phase 1 — it is not written to the account change journal. Treat it as
374 /// ephemeral: a reconnecting client should not rely on seeing a past
375 /// `BashCompleted` via the journaled history.
376 BashCompleted {
377 /// Background shell session identifier (same value returned as `bash_id`).
378 bash_id: String,
379 /// The command string that was executed.
380 command: String,
381 /// Process exit code, when available (`None` for signal/killed termination).
382 #[serde(default, skip_serializing_if = "Option::is_none")]
383 exit_code: Option<i32>,
384 /// One of: "completed" | "killed" | "error".
385 status: String,
386 },
387
388 /// Plan mode was entered.
389 PlanModeEntered {
390 /// Session identifier
391 session_id: String,
392 /// Optional reason for entering plan mode
393 #[serde(default, skip_serializing_if = "Option::is_none")]
394 reason: Option<String>,
395 /// Previous permission mode before entering plan mode
396 pre_permission_mode: String,
397 /// RFC3339 timestamp when plan mode was entered.
398 entered_at: chrono::DateTime<chrono::Utc>,
399 /// Current plan mode phase/status.
400 status: bamboo_domain::PlanModeStatus,
401 /// Path to the persisted plan file, if already available.
402 #[serde(default, skip_serializing_if = "Option::is_none")]
403 plan_file_path: Option<String>,
404 },
405
406 /// Plan mode was exited.
407 PlanModeExited {
408 /// Session identifier
409 session_id: String,
410 /// Whether the exit was approved by the user
411 approved: bool,
412 /// The permission mode restored after exiting
413 restored_mode: String,
414 /// Plan content that was reviewed, if any
415 #[serde(default, skip_serializing_if = "Option::is_none")]
416 plan: Option<String>,
417 },
418
419 /// Plan file was updated.
420 PlanFileUpdated {
421 /// Session identifier
422 session_id: String,
423 /// Path to the plan file
424 file_path: String,
425 /// Summary of the plan content (truncated)
426 content_summary: String,
427 },
428
429 /// Runner progress update emitted at the start of each agent turn.
430 ///
431 /// Used to track live execution progress (round count, current activity)
432 /// for diagnostic visibility, especially for child sessions.
433 RunnerProgress {
434 /// Session identifier
435 session_id: String,
436 /// Current turn/round count
437 round_count: u32,
438 },
439
440 /// Session title was updated (auto-generated by backend or manually renamed via PATCH).
441 SessionTitleUpdated {
442 session_id: String,
443 title: String,
444 title_version: u64,
445 source: TitleSource,
446 updated_at: chrono::DateTime<chrono::Utc>,
447 },
448
449 /// Session pinned flag was toggled via PATCH.
450 ///
451 /// Replayable metadata event. `pinned` is an idempotent boolean so the
452 /// latest event wins; `updated_at` is used by the frontend to suppress
453 /// stale replays.
454 SessionPinnedUpdated {
455 session_id: String,
456 pinned: bool,
457 updated_at: chrono::DateTime<chrono::Utc>,
458 },
459
460 /// A new session was created.
461 ///
462 /// Change-feed event: durable, journaled, carried on the account `/stream`
463 /// feed so other clients can insert the session into their list without a
464 /// full `GET /sessions` poll.
465 SessionCreated {
466 session_id: String,
467 title: String,
468 kind: bamboo_domain::SessionKind,
469 created_at: chrono::DateTime<chrono::Utc>,
470 },
471
472 /// A session was deleted.
473 ///
474 /// Change-feed event: durable, journaled. Clients remove the session from
475 /// their local list on receipt.
476 SessionDeleted { session_id: String },
477
478 /// A session's message history was cleared (session kept).
479 ///
480 /// Change-feed event: durable, journaled. Clients drop cached messages for
481 /// the session and refetch lazily.
482 SessionCleared { session_id: String },
483
484 /// A message was appended to a session.
485 ///
486 /// Change-feed event: durable, journaled. The `seq` assigned to this event
487 /// on the account feed is the message's feed coordinate (used by
488 /// `GET /history/{id}?since={seq}` to compute deltas). `content` is the
489 /// plain-text body matching what `/history` returns to the UI.
490 MessageAppended {
491 session_id: String,
492 message_id: String,
493 role: bamboo_domain::Role,
494 content: String,
495 created_at: chrono::DateTime<chrono::Utc>,
496 },
497
498 /// Execution run has started and the runner is now active.
499 ///
500 /// Emitted as the first event after a runner reservation succeeds,
501 /// before any token or tool events. Carries the `run_id` so the
502 /// frontend can correlate subsequent SSE events across reconnects.
503 ExecutionStarted {
504 /// Unique identifier for this execution run.
505 run_id: String,
506 /// Session identifier.
507 session_id: String,
508 /// ISO 8601 timestamp when the run started.
509 started_at: String,
510 },
511
512 /// Tool execution requires user approval before proceeding.
513 ///
514 /// Emitted when a permission checker determines that a tool call needs
515 /// explicit user confirmation (e.g., mutating operations in restricted
516 /// permission mode). The frontend should present the approval request and
517 /// either grant or deny it.
518 ToolApprovalRequested {
519 /// Unique identifier for the tool call awaiting approval.
520 tool_call_id: String,
521 /// Name of the tool being executed.
522 tool_name: String,
523 /// Parameters that were passed to the tool.
524 parameters: serde_json::Value,
525 },
526
527 /// A child sub-agent (out-of-process worker) hit a gated tool and proxied
528 /// the approval decision to this parent over the actor protocol (Phase 2).
529 /// The parent surfaces it to the human; the decision is routed back to the
530 /// waiting child via
531 /// `external_agents::live::deliver_approval(child_session_id, request_id, approved)`.
532 ChildApprovalRequested {
533 /// The child session whose gated tool is blocked awaiting approval.
534 child_session_id: String,
535 /// Correlates the eventual approve/deny reply back to the blocked tool.
536 request_id: String,
537 /// Name of the gated tool the child wants to run.
538 tool_name: String,
539 /// Human-readable description of the permission requested.
540 permission: String,
541 /// The concrete resource the action targets.
542 resource: String,
543 },
544
545 /// Agent execution completed successfully.
546 Complete {
547 /// Final token usage statistics
548 usage: TokenUsage,
549 },
550
551 /// Agent execution was cancelled.
552 Cancelled {
553 /// Optional human-readable message explaining the cancellation.
554 #[serde(default, skip_serializing_if = "Option::is_none")]
555 message: Option<String>,
556 },
557
558 /// Agent execution failed.
559 Error {
560 /// Error message
561 message: String,
562 },
563
564 /// A user-facing notification derived from agent activity by the backend
565 /// notification policy. Clients render this (e.g. an OS desktop notification)
566 /// after applying their own presence checks (window focus). The decision of
567 /// *whether* to notify — category, priority, preference gating, dedup — is
568 /// made server-side in `bamboo-notification`; the client just delivers it.
569 Notification {
570 /// Unique id (for client-side dedup / dismissal).
571 id: String,
572 /// Session this notification is about.
573 session_id: String,
574 /// Category, e.g. `needs_approval` | `needs_clarification` | `run_completed`
575 /// | `run_failed` | `subagent_completed` | `context_critical`.
576 category: String,
577 /// Priority: `high` | `normal` | `low`.
578 priority: String,
579 /// Short title line.
580 title: String,
581 /// Body text.
582 body: String,
583 /// Stable key for client-side coalescing within a short window.
584 #[serde(default, skip_serializing_if = "Option::is_none")]
585 dedup_key: Option<String>,
586 /// RFC3339 creation timestamp.
587 created_at: String,
588 },
589}
590
591impl AgentEvent {
592 /// Returns the session this event pertains to, when it carries one.
593 ///
594 /// Used by the account change-feed to route each event to the right
595 /// client-side session without a per-session connection. For sub-agent
596 /// events the *parent* session id is returned (that is the session a client
597 /// observes in its list). Pure streaming/diagnostic variants (`Token`,
598 /// `Complete`, …) return `None`; those are ephemeral and never ride the
599 /// account feed anyway.
600 pub fn session_id(&self) -> Option<&str> {
601 match self {
602 AgentEvent::TaskListUpdated { task_list } => Some(task_list.session_id.as_str()),
603 AgentEvent::TaskListItemProgress { session_id, .. }
604 | AgentEvent::TaskListCompleted { session_id, .. }
605 | AgentEvent::TaskEvaluationStarted { session_id, .. }
606 | AgentEvent::TaskEvaluationCompleted { session_id, .. }
607 | AgentEvent::GoldEvaluationStarted { session_id, .. }
608 | AgentEvent::GoldEvaluationCompleted { session_id, .. }
609 | AgentEvent::GoalStatusChanged { session_id, .. }
610 | AgentEvent::PlanModeEntered { session_id, .. }
611 | AgentEvent::PlanModeExited { session_id, .. }
612 | AgentEvent::PlanFileUpdated { session_id, .. }
613 | AgentEvent::RunnerProgress { session_id, .. }
614 | AgentEvent::SessionTitleUpdated { session_id, .. }
615 | AgentEvent::SessionPinnedUpdated { session_id, .. }
616 | AgentEvent::SessionCreated { session_id, .. }
617 | AgentEvent::SessionDeleted { session_id, .. }
618 | AgentEvent::SessionCleared { session_id, .. }
619 | AgentEvent::MessageAppended { session_id, .. }
620 | AgentEvent::ExecutionStarted { session_id, .. }
621 | AgentEvent::Notification { session_id, .. } => Some(session_id.as_str()),
622 AgentEvent::SubAgentStarted {
623 parent_session_id, ..
624 }
625 | AgentEvent::SubAgentEvent {
626 parent_session_id, ..
627 }
628 | AgentEvent::SubAgentHeartbeat {
629 parent_session_id, ..
630 }
631 | AgentEvent::SubAgentCompleted {
632 parent_session_id, ..
633 } => Some(parent_session_id.as_str()),
634 _ => None,
635 }
636 }
637
638 /// Whether this event belongs on the durable account change feed.
639 ///
640 /// Durable change events are low-volume, journaled to disk, and resumable
641 /// via the account `/stream` feed. Ephemeral events — token-by-token
642 /// streaming (`Token`/`ReasoningToken`/`ToolToken`), heartbeats, live
643 /// budget/pressure gauges, and raw forwarded sub-agent events — return
644 /// `false`: they stay exclusively on the per-session `/events/{id}` stream.
645 /// Keeping them off the journal and the multiplexed feed is the core
646 /// data-transfer win. This method lives in core so both the server and the
647 /// engine forwarder can filter before cloning onto the feed.
648 pub fn is_durable_change(&self) -> bool {
649 matches!(
650 self,
651 AgentEvent::MessageAppended { .. }
652 | AgentEvent::SessionCreated { .. }
653 | AgentEvent::SessionDeleted { .. }
654 | AgentEvent::SessionCleared { .. }
655 | AgentEvent::SessionTitleUpdated { .. }
656 | AgentEvent::SessionPinnedUpdated { .. }
657 | AgentEvent::TaskListUpdated { .. }
658 | AgentEvent::TaskListItemProgress { .. }
659 | AgentEvent::TaskListCompleted { .. }
660 | AgentEvent::TaskEvaluationCompleted { .. }
661 | AgentEvent::PlanModeEntered { .. }
662 | AgentEvent::PlanModeExited { .. }
663 | AgentEvent::PlanFileUpdated { .. }
664 | AgentEvent::SubAgentStarted { .. }
665 | AgentEvent::SubAgentCompleted { .. }
666 | AgentEvent::NeedClarification { .. }
667 | AgentEvent::ToolApprovalRequested { .. }
668 | AgentEvent::ExecutionStarted { .. }
669 | AgentEvent::Complete { .. }
670 | AgentEvent::Cancelled { .. }
671 | AgentEvent::Error { .. }
672 )
673 }
674}
675
676fn default_allow_custom() -> bool {
677 true
678}
679
680/// Gold evaluation checkpoint.
681#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
682#[serde(rename_all = "snake_case")]
683pub enum GoldCheckpoint {
684 PostRound,
685 Terminal,
686}
687
688impl GoldCheckpoint {
689 pub fn as_str(self) -> &'static str {
690 match self {
691 Self::PostRound => "post_round",
692 Self::Terminal => "terminal",
693 }
694 }
695}
696
697/// Gold evaluator decision.
698#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
699#[serde(rename_all = "snake_case")]
700pub enum GoldDecision {
701 Continue,
702 Achieved,
703 Blocked,
704 NeedInput,
705 Exhausted,
706}
707
708impl GoldDecision {
709 pub fn as_str(self) -> &'static str {
710 match self {
711 Self::Continue => "continue",
712 Self::Achieved => "achieved",
713 Self::Blocked => "blocked",
714 Self::NeedInput => "need_input",
715 Self::Exhausted => "exhausted",
716 }
717 }
718}
719
720/// Confidence level for a Gold evaluation result.
721#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
722#[serde(rename_all = "snake_case")]
723pub enum GoldConfidence {
724 Low,
725 Medium,
726 High,
727}
728
729impl GoldConfidence {
730 pub fn as_str(self) -> &'static str {
731 match self {
732 Self::Low => "low",
733 Self::Medium => "medium",
734 Self::High => "high",
735 }
736 }
737
738 /// Ordinal rank for threshold comparisons (`Low` < `Medium` < `High`).
739 pub fn rank(self) -> u8 {
740 match self {
741 Self::Low => 0,
742 Self::Medium => 1,
743 Self::High => 2,
744 }
745 }
746
747 /// Whether this confidence meets or exceeds the given floor.
748 pub fn meets(self, floor: GoldConfidence) -> bool {
749 self.rank() >= floor.rank()
750 }
751}
752
753/// Source that triggered a session title update.
754#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
755#[serde(rename_all = "snake_case")]
756pub enum TitleSource {
757 Auto,
758 Manual,
759 Fallback,
760}
761
762/// Re-exported shared token usage type.
763///
764/// See [`bamboo_domain::TokenUsage`] for the canonical definition.
765pub use bamboo_domain::TokenUsage;
766
767pub use bamboo_domain::budget_types::TokenBudgetUsage;
768
769#[cfg(test)]
770mod tests {
771 use super::*;
772 use bamboo_domain::{TaskItem, TaskItemStatus, TaskList};
773
774 fn sample_task_list() -> TaskList {
775 TaskList {
776 session_id: "session-1".to_string(),
777 title: "Task List".to_string(),
778 items: vec![TaskItem {
779 id: "task_1".to_string(),
780 description: "Implement event rename".to_string(),
781 status: TaskItemStatus::InProgress,
782 depends_on: Vec::new(),
783 notes: "Implementing".to_string(),
784 ..TaskItem::default()
785 }],
786 created_at: Utc::now(),
787 updated_at: Utc::now(),
788 }
789 }
790
791 #[test]
792 fn task_list_updated_serializes_with_task_names() {
793 let event = AgentEvent::TaskListUpdated {
794 task_list: sample_task_list(),
795 };
796
797 let value = serde_json::to_value(event).expect("event should serialize");
798 assert_eq!(value["type"], "task_list_updated");
799 assert!(value.get("task_list").is_some());
800 assert!(value.get("todo_list").is_none());
801 }
802
803 #[test]
804 fn cancelled_serializes_with_snake_case_type() {
805 let event = AgentEvent::Cancelled {
806 message: Some("Agent execution cancelled by user".to_string()),
807 };
808
809 let value = serde_json::to_value(event).expect("event should serialize");
810 assert_eq!(value["type"], "cancelled");
811 assert_eq!(
812 value["message"],
813 serde_json::Value::String("Agent execution cancelled by user".to_string())
814 );
815 }
816
817 #[test]
818 fn task_evaluation_completed_serializes_with_task_type() {
819 let event = AgentEvent::TaskEvaluationCompleted {
820 session_id: "session-1".to_string(),
821 updates_count: 2,
822 reasoning: "Updated statuses".to_string(),
823 };
824
825 let value = serde_json::to_value(event).expect("event should serialize");
826 assert_eq!(value["type"], "task_evaluation_completed");
827 }
828
829 #[test]
830 fn gold_evaluation_completed_serializes_with_gold_type_and_fields() {
831 let event = AgentEvent::GoldEvaluationCompleted {
832 session_id: "session-1".to_string(),
833 checkpoint: GoldCheckpoint::PostRound,
834 iteration: 3,
835 decision: GoldDecision::Continue,
836 confidence: GoldConfidence::Medium,
837 reasoning: "Need one more iteration".to_string(),
838 };
839
840 let value = serde_json::to_value(event).expect("event should serialize");
841 assert_eq!(value["type"], "gold_evaluation_completed");
842 assert_eq!(value["checkpoint"], "post_round");
843 assert_eq!(value["iteration"], 3);
844 assert_eq!(value["decision"], "continue");
845 assert_eq!(value["confidence"], "medium");
846 assert_eq!(value["reasoning"], "Need one more iteration");
847 }
848
849 #[test]
850 fn gold_evaluation_started_deserializes() {
851 let json = serde_json::json!({
852 "type": "gold_evaluation_started",
853 "session_id": "session-1",
854 "checkpoint": "terminal",
855 "iteration": 7
856 });
857
858 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
859 match event {
860 AgentEvent::GoldEvaluationStarted {
861 session_id,
862 checkpoint,
863 iteration,
864 } => {
865 assert_eq!(session_id, "session-1");
866 assert_eq!(checkpoint, GoldCheckpoint::Terminal);
867 assert_eq!(iteration, 7);
868 }
869 other => panic!("unexpected event: {other:?}"),
870 }
871 }
872
873 #[test]
874 fn context_compression_status_serializes_with_phase_and_status() {
875 let event = AgentEvent::ContextCompressionStatus {
876 phase: "mid-turn".to_string(),
877 status: "started".to_string(),
878 };
879
880 let value = serde_json::to_value(event).expect("event should serialize");
881 assert_eq!(value["type"], "context_compression_status");
882 assert_eq!(value["phase"], "mid-turn");
883 assert_eq!(value["status"], "started");
884 }
885
886 #[test]
887 fn need_clarification_serializes_with_new_fields() {
888 let event = AgentEvent::NeedClarification {
889 question: "Continue?".to_string(),
890 options: Some(vec!["Yes".to_string(), "No".to_string()]),
891 tool_call_id: Some("tool-1".to_string()),
892 tool_name: Some("conclusion_with_options".to_string()),
893 allow_custom: false,
894 };
895
896 let value = serde_json::to_value(event).expect("event should serialize");
897 assert_eq!(value["type"], "need_clarification");
898 assert_eq!(value["question"], "Continue?");
899 assert_eq!(value["options"], serde_json::json!(["Yes", "No"]));
900 assert_eq!(value["tool_call_id"], "tool-1");
901 assert_eq!(value["tool_name"], "conclusion_with_options");
902 assert_eq!(value["allow_custom"], false);
903 }
904
905 #[test]
906 fn need_clarification_deserializes_from_old_format_without_new_fields() {
907 let json = serde_json::json!({
908 "type": "need_clarification",
909 "question": "Continue?",
910 "options": ["Yes", "No"]
911 });
912
913 let event: AgentEvent =
914 serde_json::from_value(json).expect("should deserialize old format");
915 match event {
916 AgentEvent::NeedClarification {
917 question,
918 options,
919 tool_call_id,
920 tool_name,
921 allow_custom,
922 } => {
923 assert_eq!(question, "Continue?");
924 assert_eq!(options, Some(vec!["Yes".to_string(), "No".to_string()]));
925 assert_eq!(tool_call_id, None);
926 assert_eq!(tool_name, None);
927 assert!(allow_custom); // default_allow_custom returns true
928 }
929 other => panic!("unexpected event: {other:?}"),
930 }
931 }
932
933 #[test]
934 fn need_clarification_deserializes_with_allow_custom_false() {
935 let json = serde_json::json!({
936 "type": "need_clarification",
937 "question": "Pick one",
938 "allow_custom": false
939 });
940
941 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
942 match event {
943 AgentEvent::NeedClarification {
944 question,
945 options,
946 tool_call_id,
947 tool_name,
948 allow_custom,
949 } => {
950 assert_eq!(question, "Pick one");
951 assert_eq!(options, None);
952 assert_eq!(tool_call_id, None);
953 assert_eq!(tool_name, None);
954 assert!(!allow_custom);
955 }
956 other => panic!("unexpected event: {other:?}"),
957 }
958 }
959
960 #[test]
961 fn plan_mode_entered_serializes_correctly() {
962 let entered_at = Utc::now();
963 let event = AgentEvent::PlanModeEntered {
964 session_id: "sess-1".to_string(),
965 reason: Some("Complex refactor".to_string()),
966 pre_permission_mode: "default".to_string(),
967 entered_at,
968 status: bamboo_domain::PlanModeStatus::Exploring,
969 plan_file_path: None,
970 };
971
972 let value = serde_json::to_value(event).expect("event should serialize");
973 assert_eq!(value["type"], "plan_mode_entered");
974 assert_eq!(value["session_id"], "sess-1");
975 assert_eq!(value["reason"], "Complex refactor");
976 assert_eq!(value["pre_permission_mode"], "default");
977 assert_eq!(value["status"], "exploring");
978 // Compare against serde's own serialization (RFC3339 with `Z` for UTC),
979 // not `to_rfc3339()` which emits a `+00:00` offset instead.
980 assert_eq!(
981 value["entered_at"],
982 serde_json::to_value(entered_at).unwrap()
983 );
984 }
985
986 #[test]
987 fn plan_mode_exited_serializes_correctly() {
988 let event = AgentEvent::PlanModeExited {
989 session_id: "sess-1".to_string(),
990 approved: true,
991 restored_mode: "accept_edits".to_string(),
992 plan: Some("# Plan\n1. Step one".to_string()),
993 };
994
995 let value = serde_json::to_value(event).expect("event should serialize");
996 assert_eq!(value["type"], "plan_mode_exited");
997 assert_eq!(value["session_id"], "sess-1");
998 assert_eq!(value["approved"], true);
999 assert_eq!(value["restored_mode"], "accept_edits");
1000 assert_eq!(value["plan"], "# Plan\n1. Step one");
1001 }
1002
1003 #[test]
1004 fn plan_file_updated_serializes_correctly() {
1005 let event = AgentEvent::PlanFileUpdated {
1006 session_id: "sess-1".to_string(),
1007 file_path: "/tmp/plans/sess-1.md".to_string(),
1008 content_summary: "Implementation plan for feature X".to_string(),
1009 };
1010
1011 let value = serde_json::to_value(event).expect("event should serialize");
1012 assert_eq!(value["type"], "plan_file_updated");
1013 assert_eq!(value["session_id"], "sess-1");
1014 assert_eq!(value["file_path"], "/tmp/plans/sess-1.md");
1015 assert_eq!(
1016 value["content_summary"],
1017 "Implementation plan for feature X"
1018 );
1019 }
1020
1021 #[test]
1022 fn tool_approval_requested_serializes_correctly() {
1023 let event = AgentEvent::ToolApprovalRequested {
1024 tool_call_id: "call-abc".to_string(),
1025 tool_name: "Write".to_string(),
1026 parameters: serde_json::json!({"file_path": "/tmp/test.txt"}),
1027 };
1028
1029 let value = serde_json::to_value(event).expect("event should serialize");
1030 assert_eq!(value["type"], "tool_approval_requested");
1031 assert_eq!(value["tool_call_id"], "call-abc");
1032 assert_eq!(value["tool_name"], "Write");
1033 assert_eq!(
1034 value["parameters"],
1035 serde_json::json!({"file_path": "/tmp/test.txt"})
1036 );
1037 }
1038
1039 #[test]
1040 fn tool_approval_requested_deserializes_correctly() {
1041 let json = serde_json::json!({
1042 "type": "tool_approval_requested",
1043 "tool_call_id": "call-xyz",
1044 "tool_name": "Bash",
1045 "parameters": {"command": "ls -la"}
1046 });
1047
1048 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1049 match event {
1050 AgentEvent::ToolApprovalRequested {
1051 tool_call_id,
1052 tool_name,
1053 parameters,
1054 } => {
1055 assert_eq!(tool_call_id, "call-xyz");
1056 assert_eq!(tool_name, "Bash");
1057 assert_eq!(parameters, serde_json::json!({"command": "ls -la"}));
1058 }
1059 other => panic!("unexpected event: {other:?}"),
1060 }
1061 }
1062
1063 #[test]
1064 fn session_title_updated_round_trips_with_source_variants() {
1065 use chrono::Utc;
1066 let event = AgentEvent::SessionTitleUpdated {
1067 session_id: "sess-1".to_string(),
1068 title: "My title".to_string(),
1069 title_version: 3,
1070 source: TitleSource::Auto,
1071 updated_at: Utc::now(),
1072 };
1073 let json = serde_json::to_string(&event).unwrap();
1074 assert!(
1075 json.contains("\"type\":\"session_title_updated\""),
1076 "json: {json}"
1077 );
1078 assert!(json.contains("\"source\":\"auto\""), "json: {json}");
1079 let _decoded: AgentEvent = serde_json::from_str(&json).unwrap();
1080 }
1081
1082 #[test]
1083 fn plan_mode_events_deserialize_without_optional_fields() {
1084 let json = serde_json::json!({
1085 "type": "plan_mode_entered",
1086 "session_id": "sess-1",
1087 "pre_permission_mode": "default",
1088 "entered_at": "2025-01-01T00:00:00Z",
1089 "status": "exploring"
1090 });
1091
1092 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1093 match event {
1094 AgentEvent::PlanModeEntered {
1095 session_id,
1096 reason,
1097 pre_permission_mode,
1098 entered_at,
1099 status,
1100 plan_file_path,
1101 } => {
1102 assert_eq!(session_id, "sess-1");
1103 assert_eq!(reason, None);
1104 assert_eq!(pre_permission_mode, "default");
1105 assert_eq!(entered_at.to_rfc3339(), "2025-01-01T00:00:00+00:00");
1106 assert_eq!(status, bamboo_domain::PlanModeStatus::Exploring);
1107 assert_eq!(plan_file_path, None);
1108 }
1109 other => panic!("unexpected event: {other:?}"),
1110 }
1111 }
1112}