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