Skip to main content

oxios_kernel/
event_bus.rs

1//! Event bus: inter-agent communication via `oxi_sdk::EventBus<KernelEvent>`.
2//!
3//! The event bus is the "pipe" of Oxios. All agents communicate
4//! through kernel events published on the bus.
5//!
6//! After RFC-014 Phase C, this module no longer owns the broadcast channel —
7//! it reuses `oxi_sdk::EventBus<E>`, which is a generic wrapper over
8//! `tokio::sync::broadcast`. The only Oxios-specific bits are:
9//!
10//! - `KernelEvent` enum (oxios-internal event vocabulary)
11//! - `kernel_event_to_audit_action` mapping for the audit trail
12//! - `attach_audit_trail` helper (subscribes the bus to the trail)
13
14use oxi_sdk::EventBus as SdkEventBus;
15use oxi_sdk::observability::{AuditAction, AuditTrail};
16use serde::{Deserialize, Serialize};
17use std::sync::Arc;
18use uuid::Uuid;
19
20use crate::types::AgentId;
21
22/// Kernel event bus — generic SDK bus specialised for `KernelEvent`.
23///
24/// The broadcast channel is owned by `oxi_sdk::EventBus`; this type alias
25/// just makes the call sites read more naturally (`crate::event_bus::EventBus`
26/// instead of `oxi_sdk::EventBus<KernelEvent>`).
27pub type EventBus = SdkEventBus<KernelEvent>;
28
29/// Events that flow through the kernel event bus.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub enum KernelEvent {
32    /// A new agent has been created.
33    AgentCreated {
34        /// The new agent's ID.
35        id: AgentId,
36        /// The agent's name/goal.
37        name: String,
38    },
39    /// An agent has started executing.
40    AgentStarted {
41        /// The agent's ID.
42        id: AgentId,
43    },
44    /// An agent has been stopped.
45    ///
46    /// Carries `success` so consumers can distinguish a normal completion
47    /// (`success: true`) from an evaluation/assessment failure
48    /// (`success: false`). Infrastructure errors (panic, timeout) emit
49    /// `AgentFailed` instead.
50    AgentStopped {
51        /// The agent's ID.
52        id: AgentId,
53        /// Whether the agent's result passed evaluation. Mirrors
54        /// `ExecutionResult.success` from the Ok path; `false` on the
55        /// kill/terminate path (user-initiated stop).
56        #[serde(default)]
57        success: bool,
58    },
59    /// An agent has encountered a failure.
60    AgentFailed {
61        /// The agent's ID.
62        id: AgentId,
63        /// Description of the error.
64        error: String,
65    },
66    /// A message has been received from an agent.
67    MessageReceived {
68        /// The sending agent's ID.
69        from: AgentId,
70        /// Message content.
71        content: String,
72    },
73    /// An agent has produced output.
74    AgentOutput {
75        /// The session this output belongs to.
76        session_id: String,
77        /// The agent's ID.
78        agent_id: AgentId,
79        /// The output content.
80        output: String,
81    },
82    /// A HitL approval request has been submitted.
83    ApprovalRequested {
84        /// The approval request ID.
85        id: uuid::Uuid,
86        /// The tool requesting approval.
87        tool_name: String,
88        /// The action requiring approval.
89        action: String,
90        /// The resource involved.
91        resource: String,
92        /// Reason for the request.
93        reason: String,
94        /// The session ID that triggered this request.
95        session_id: Option<String>,
96    },
97    /// A HitL approval has been resolved (approved or rejected).
98    ApprovalResolved {
99        /// The approval request ID.
100        id: uuid::Uuid,
101        /// Whether it was approved (true) or rejected (false).
102        approved: bool,
103    },
104    /// A memory entry was stored.
105    MemoryStored {
106        /// Memory entry ID.
107        id: String,
108        /// Memory type label.
109        memory_type: String,
110        /// Source of the memory.
111        source: String,
112    },
113    /// Memories were recalled for a new session.
114    MemoryRecalled {
115        /// The recall query.
116        query: String,
117        /// Number of memories returned.
118        count: usize,
119    },
120    /// Multi-agent group created.
121    AgentGroupCreated {
122        /// The group's ID.
123        group_id: uuid::Uuid,
124        /// Number of agents in the group.
125        agent_count: usize,
126    },
127    /// An agent in a group completed.
128    AgentGroupMemberCompleted {
129        /// The group's ID.
130        group_id: uuid::Uuid,
131        /// The agent's ID.
132        agent_id: uuid::Uuid,
133        /// Whether the agent succeeded.
134        success: bool,
135    },
136    /// A new Project has been created (RFC-011).
137    ProjectCreated {
138        /// The project's ID.
139        project_id: uuid::Uuid,
140        /// The project's name.
141        name: String,
142        /// How it was created.
143        source: String,
144    },
145    /// A Project has been activated (RFC-011).
146    ProjectActivated {
147        /// The project's ID.
148        project_id: uuid::Uuid,
149        /// The project's name.
150        name: String,
151    },
152
153    // ── RFC-015 Chat Transparency ─────────────────────────────
154    // Real-time events emitted by AgentRuntime during tool execution
155    // and streaming. Web channel converts these to WS chunks.
156    /// A tool execution has started (real-time, RFC-015).
157    ToolExecutionStarted {
158        /// Session this tool call belongs to.
159        session_id: String,
160        /// Name of the tool (e.g. "read_file", "bash", "memory_recall").
161        tool_name: String,
162        /// Provider-specific tool call ID used to correlate start/end.
163        tool_call_id: String,
164        /// Tool input arguments (JSON).
165        tool_args: serde_json::Value,
166        /// Semantic context inferred by oxi-agent 0.32+ from tool name/args
167        /// (e.g. WebSearch, PageVisit). `None` for tools without context mapping.
168        #[serde(default, skip_serializing_if = "Option::is_none")]
169        context: Option<serde_json::Value>,
170    },
171    /// A tool execution has finished (real-time, RFC-015).
172    ToolExecutionFinished {
173        /// Session this tool call belongs to.
174        session_id: String,
175        /// Provider-specific tool call ID.
176        tool_call_id: String,
177        /// Name of the tool.
178        tool_name: String,
179        /// Wall-clock duration in milliseconds.
180        duration_ms: u64,
181        /// Whether the tool returned an error.
182        is_error: bool,
183        /// Truncated output (max ~500 chars) for streaming.
184        output_summary: String,
185    },
186    /// A tool execution emitted a progress update (real-time, RFC-015).
187    ToolExecutionProgress {
188        /// Session this tool call belongs to.
189        session_id: String,
190        /// Provider-specific tool call ID.
191        tool_call_id: String,
192        /// Name of the tool.
193        tool_name: String,
194        /// Human-readable progress text (already-formatted by the tool).
195        progress: String,
196        /// Tab that emitted this progress event, if the upstream tool tracks
197        /// tabs. `None` for tools that don't have a tab concept (e.g. legacy
198        /// oxi-agent versions that don't propagate `tab_id`).
199        #[serde(default, skip_serializing_if = "Option::is_none")]
200        tab_id: Option<Uuid>,
201        /// Semantic context from the tool call (e.g. PageVisit, WebSearch).
202        /// Stored as `serde_json::Value` to decouple kernel events from
203        /// oxi-sdk's internal `ToolCallContext` enum. UI consumers that
204        /// understand a context variant render it richly; older consumers
205        /// simply ignore the field.
206        #[serde(default, skip_serializing_if = "Option::is_none")]
207        context: Option<serde_json::Value>,
208    },
209    /// Memory was recalled during agent execution (RFC-015).
210    MemoryRecallUsed {
211        /// Session this recall belongs to.
212        session_id: String,
213        /// The recall query.
214        query: String,
215        /// Number of memories returned.
216        count: usize,
217        /// Memory tier source ("hot" | "warm" | "cold").
218        source: String,
219    },
220    /// Token usage update (RFC-015).
221    TokenUsageUpdate {
222        /// Session this usage belongs to.
223        session_id: String,
224        /// Cumulative input tokens.
225        input_tokens: u64,
226        /// Cumulative output tokens.
227        output_tokens: u64,
228    },
229    /// Reasoning/compaction fragment (RFC-015).
230    ReasoningFragment {
231        /// Session this fragment belongs to.
232        session_id: String,
233        /// The fragment text (chain-of-thought, compaction summary, etc).
234        content: String,
235        /// Source label: "chain_of_thought" | "compaction" | "reflection".
236        source: String,
237    },
238    /// Partial tool-call arguments streamed by the LLM (RFC-015 Phase C).
239    ///
240    /// Emitted by oxi 0.58+ (`AgentEvent::ToolCallDelta`) while the model is
241    /// still constructing a tool call, before `ToolExecutionStarted`. Each
242    /// `args_delta` is a raw JSON fragment; consumers accumulate per
243    /// `tool_call_id`.
244    ToolArgsDelta {
245        /// Session this delta belongs to.
246        session_id: String,
247        /// Tool call identifier (matches the later `ToolExecutionStarted`).
248        tool_call_id: String,
249        /// Raw JSON argument fragment from the LLM stream.
250        args_delta: String,
251    },
252
253    /// Compaction was triggered (RFC-035 gap 2 observability).
254    ///
255    /// Emitted when `oxi_sdk::CompactionEvent::Triggered` fires (0.53.0+).
256    /// `source` is one of:
257    /// - `"provider-reported"` — provider-reported `usage.input_tokens` drove
258    ///   the trigger (ground truth; gap 2's primary signal)
259    /// - `"bytes/4 heuristic (cold start)"` — legacy heuristic; only on turn 1
260    ///   before any `ProviderEvent::Done` has been observed
261    /// - `"empty"` — empty context (no trigger source)
262    CompactionTriggered {
263        /// Session this compaction belongs to.
264        session_id: Option<String>,
265        /// The trigger source label from `CompactionEvent::Triggered::source`.
266        source: String,
267    },
268    // ── Calendar ──────────────────────────────────────────────
269    /// A calendar event was created.
270    CalendarEventCreated {
271        /// Event UID.
272        uid: String,
273        /// Event title.
274        title: String,
275        /// Start time.
276        start: String,
277        /// End time.
278        end: String,
279    },
280    /// A calendar event was updated.
281    CalendarEventUpdated {
282        /// Event UID.
283        uid: String,
284        /// Event title.
285        title: String,
286    },
287    /// A calendar event was deleted.
288    CalendarEventDeleted {
289        /// Event UID.
290        uid: String,
291        /// Event title.
292        title: String,
293    },
294    /// An email has been sent.
295    EmailSent {
296        /// Email subject.
297        subject: String,
298        /// SMTP message ID.
299        message_id: String,
300        /// Template name (if template was used/saved).
301        #[serde(default, skip_serializing_if = "Option::is_none")]
302        template_name: Option<String>,
303    },
304
305    // ── Knowledge ──────────────────────────────────────────────
306    /// A knowledge note was persisted (hook, user, or tool).
307    KnowledgePersisted {
308        session_id: String,
309        message_index: usize,
310        path: String,
311        source: String, // "hook", "user", "tool"
312    },
313    /// A knowledge note was removed by user action.
314    KnowledgeRemoved {
315        session_id: String,
316        message_index: usize,
317    },
318    /// A question was posed to the user by the agent (RFC-027, `ask_user`).
319    /// The frontend renders an input/option picker and resolves the
320    /// pending oneshot via a separate response endpoint.
321    AskUserRequest {
322        /// Unique request ID — used by the response handler to resolve
323        /// the oneshot the tool is awaiting.
324        id: String,
325        /// The question text the user sees.
326        question: String,
327        /// Optional structured options. Empty when the question is open-ended.
328        options: Vec<String>,
329    },
330    // ── Persona (agent-authored writes are security-reviewed) ───────────
331    /// A persona was created (by an agent tool, the HTTP API, or the UI).
332    PersonaCreated {
333        /// Persona ID.
334        id: String,
335        /// Persona display name.
336        name: String,
337        /// Whether it was registered enabled.
338        enabled: bool,
339        /// Origin of the change: "agent" | "api" | "ui".
340        source: String,
341    },
342    /// A persona was updated.
343    PersonaUpdated {
344        /// Persona ID.
345        id: String,
346        /// Persona display name.
347        name: String,
348        /// Origin of the change: "agent" | "api" | "ui".
349        source: String,
350    },
351    // ── Integration install (RFC-041 M3) ────────────────────────────────────
352    // These ride the same SSE channel as tool execution progress so the UI
353    // can stream install output without opening a second connection. Routing
354    // is by `job_id` (opaque to clients; the daemon mints it via uuid).
355    /// An integration install job has started.
356    IntegrationInstallStarted {
357        /// Opaque job ID; clients correlate all subsequent events with this.
358        job_id: String,
359        /// Integration registry id (e.g. "github").
360        integration_id: String,
361        /// Human-readable label.
362        label: String,
363    },
364    /// Incremental progress — a stdout line or stage transition.
365    IntegrationInstallProgress {
366        job_id: String,
367        integration_id: String,
368        /// One line of output or a stage label (e.g. "fetching", "extracting").
369        line: String,
370    },
371    /// Install completed successfully.
372    IntegrationInstallCompleted {
373        job_id: String,
374        integration_id: String,
375        /// Final command + summary output for the audit log and UI.
376        command: String,
377        output: String,
378        exit_code: Option<i32>,
379    },
380    /// Install failed (non-zero exit or spawn error).
381    IntegrationInstallFailed {
382        job_id: String,
383        integration_id: String,
384        error: String,
385    },
386}
387
388/// Convert a KernelEvent to an AuditAction for the audit trail.
389pub fn kernel_event_to_audit_action(event: &KernelEvent) -> AuditAction {
390    match event {
391        KernelEvent::AgentCreated { name, .. } => AuditAction::AgentSpawn {
392            task_type: name.clone(),
393        },
394        KernelEvent::AgentStarted { .. } => AuditAction::AgentSpawn {
395            task_type: "started".to_string(),
396        },
397        KernelEvent::AgentStopped { success, .. } => AuditAction::AgentExit {
398            reason: if *success {
399                "completed".to_string()
400            } else {
401                "stopped".to_string()
402            },
403        },
404        KernelEvent::AgentFailed { error, .. } => AuditAction::AgentExit {
405            reason: error.clone(),
406        },
407        KernelEvent::MessageReceived { content, .. } => AuditAction::Other {
408            detail: format!("message: {content}"),
409        },
410        KernelEvent::AgentOutput { output, .. } => AuditAction::Other {
411            detail: format!("agent_output:{output}"),
412        },
413        KernelEvent::ApprovalRequested {
414            id,
415            action,
416            resource,
417            ..
418        } => AuditAction::Other {
419            detail: format!("approval_requested:{id}:{action}:{resource}"),
420        },
421        KernelEvent::ApprovalResolved { id, approved } => AuditAction::Other {
422            detail: format!("approval_resolved:{id}:{approved}"),
423        },
424        KernelEvent::MemoryStored {
425            id, memory_type, ..
426        } => AuditAction::MemoryWrite {
427            entry_id: format!("{id}:{memory_type}"),
428        },
429        KernelEvent::MemoryRecalled { query, count } => AuditAction::MemoryRead {
430            entry_id: format!("query:{query}:{count}results"),
431        },
432        KernelEvent::AgentGroupCreated {
433            group_id,
434            agent_count,
435        } => AuditAction::Other {
436            detail: format!("group_created:{group_id}:{agent_count}agents"),
437        },
438        KernelEvent::AgentGroupMemberCompleted {
439            group_id,
440            agent_id,
441            success,
442        } => AuditAction::Other {
443            detail: format!("group_member_completed:{group_id}:{agent_id}:{success}"),
444        },
445        KernelEvent::ProjectCreated {
446            project_id: _,
447            name,
448            source,
449        } => AuditAction::Other {
450            detail: format!("project_created:{name}:{source}"),
451        },
452        KernelEvent::ProjectActivated {
453            project_id: _,
454            name,
455        } => AuditAction::Other {
456            detail: format!("project_activated:{name}"),
457        },
458        // ── RFC-015 ──
459        KernelEvent::ToolExecutionStarted { tool_name, .. } => AuditAction::Other {
460            detail: format!("tool_started:{tool_name}"),
461        },
462        KernelEvent::ToolExecutionFinished {
463            tool_name,
464            is_error,
465            ..
466        } => AuditAction::Other {
467            detail: format!(
468                "tool_finished:{tool_name}:{}",
469                if *is_error { "error" } else { "ok" }
470            ),
471        },
472        KernelEvent::ToolExecutionProgress {
473            tool_name,
474            tab_id,
475            context,
476            ..
477        } => AuditAction::Other {
478            detail: {
479                let mut d = format!("tool_progress:{tool_name}");
480                if let Some(id) = tab_id {
481                    d.push_str(&format!(":tab={id}"));
482                }
483                if let Some(ctx) = context
484                    .as_ref()
485                    .and_then(|c| c.get("kind"))
486                    .and_then(|k| k.as_str())
487                {
488                    d.push_str(&format!(":{ctx}"));
489                }
490                d
491            },
492        },
493        KernelEvent::MemoryRecallUsed { query, count, .. } => AuditAction::MemoryRead {
494            entry_id: format!("recall:{query}:{count}results"),
495        },
496        KernelEvent::TokenUsageUpdate {
497            input_tokens,
498            output_tokens,
499            ..
500        } => AuditAction::Other {
501            detail: format!("tokens:in={input_tokens}:out={output_tokens}"),
502        },
503        KernelEvent::ReasoningFragment { source, .. } => AuditAction::Other {
504            detail: format!("reasoning:{source}"),
505        },
506        KernelEvent::ToolArgsDelta { tool_call_id, .. } => AuditAction::Other {
507            detail: format!("tool_args_delta:{tool_call_id}"),
508        },
509        KernelEvent::CompactionTriggered { source, .. } => AuditAction::Other {
510            detail: format!("compaction:triggered:{source}"),
511        },
512        KernelEvent::CalendarEventCreated { uid, title, .. } => AuditAction::Other {
513            detail: format!("calendar:created:{uid}:{title}"),
514        },
515        KernelEvent::CalendarEventUpdated { uid, title } => AuditAction::Other {
516            detail: format!("calendar:updated:{uid}:{title}"),
517        },
518        KernelEvent::CalendarEventDeleted { uid, title } => AuditAction::Other {
519            detail: format!("calendar:deleted:{uid}:{title}"),
520        },
521        KernelEvent::EmailSent {
522            subject,
523            message_id,
524            template_name,
525        } => AuditAction::Other {
526            detail: format!("email:sent:{subject} (msg={message_id}, tpl={template_name:?})"),
527        },
528        KernelEvent::KnowledgePersisted {
529            session_id,
530            message_index,
531            path,
532            source,
533        } => AuditAction::Other {
534            detail: format!("knowledge:persisted:{session_id}:{message_index}:{path}:{source}"),
535        },
536        KernelEvent::KnowledgeRemoved {
537            session_id,
538            message_index,
539        } => AuditAction::Other {
540            detail: format!("knowledge:removed:{session_id}:{message_index}"),
541        },
542        KernelEvent::AskUserRequest { id, question, .. } => AuditAction::Other {
543            detail: format!("ask_user:{id}:{question}"),
544        },
545        KernelEvent::PersonaCreated {
546            id, name, source, ..
547        } => AuditAction::Other {
548            detail: format!("persona:created:{id}:{name}:{source}"),
549        },
550        KernelEvent::PersonaUpdated { id, name, source } => AuditAction::Other {
551            detail: format!("persona:updated:{id}:{name}:{source}"),
552        },
553        KernelEvent::IntegrationInstallStarted {
554            job_id,
555            integration_id,
556            ..
557        } => AuditAction::Other {
558            detail: format!("install:started:{integration_id}:{job_id}"),
559        },
560        KernelEvent::IntegrationInstallProgress {
561            job_id,
562            integration_id,
563            ..
564        } => AuditAction::Other {
565            detail: format!("install:progress:{integration_id}:{job_id}"),
566        },
567        KernelEvent::IntegrationInstallCompleted {
568            job_id,
569            integration_id,
570            exit_code,
571            ..
572        } => AuditAction::Other {
573            detail: format!(
574                "install:completed:{integration_id}:{job_id}:exit={}",
575                exit_code
576                    .map(|c| c.to_string())
577                    .unwrap_or_else(|| "?".into())
578            ),
579        },
580        KernelEvent::IntegrationInstallFailed {
581            job_id,
582            integration_id,
583            ..
584        } => AuditAction::Other {
585            detail: format!("install:failed:{integration_id}:{job_id}"),
586        },
587    }
588}
589
590/// Extract agent ID from a KernelEvent variant.
591fn extract_agent_id(event: &KernelEvent) -> String {
592    match event {
593        KernelEvent::AgentCreated { id, .. } => id.to_string(),
594        KernelEvent::AgentStarted { id, .. } => id.to_string(),
595        KernelEvent::AgentStopped { id, .. } => id.to_string(),
596        KernelEvent::AgentFailed { id, .. } => id.to_string(),
597        KernelEvent::MessageReceived { from, .. } => from.to_string(),
598        KernelEvent::AgentOutput { agent_id, .. } => agent_id.to_string(),
599        KernelEvent::AgentGroupMemberCompleted { agent_id, .. } => agent_id.to_string(),
600        KernelEvent::ProjectActivated { project_id, .. } => format!("project:{project_id}"),
601        // RFC-015: session-scoped events use session_id as the subject
602        KernelEvent::ToolExecutionStarted { session_id, .. } => format!("session:{session_id}"),
603        KernelEvent::ToolExecutionFinished { session_id, .. } => format!("session:{session_id}"),
604        KernelEvent::ToolExecutionProgress { session_id, .. } => format!("session:{session_id}"),
605        KernelEvent::MemoryRecallUsed { session_id, .. } => format!("session:{session_id}"),
606        KernelEvent::TokenUsageUpdate { session_id, .. } => format!("session:{session_id}"),
607        KernelEvent::ReasoningFragment { session_id, .. } => format!("session:{session_id}"),
608        KernelEvent::ToolArgsDelta { session_id, .. } => format!("session:{session_id}"),
609        KernelEvent::KnowledgePersisted { session_id, .. } => format!("session:{session_id}"),
610        KernelEvent::KnowledgeRemoved { session_id, .. } => format!("session:{session_id}"),
611        KernelEvent::CompactionTriggered { session_id, .. } => session_id
612            .as_ref()
613            .map(|s| format!("session:{s}"))
614            .unwrap_or_else(|| "system".to_string()),
615        _ => "system".to_string(),
616    }
617}
618
619/// Subscribe the audit trail to all kernel events.
620///
621/// The bus is broadcast-based; this spawns a long-running task that
622/// forwards every event into the audit trail as a structured entry.
623/// Lagged subscribers are logged and recovered.
624pub fn attach_audit_trail(bus: &EventBus, audit: Arc<AuditTrail>) {
625    let mut rx = bus.subscribe();
626    tokio::spawn(async move {
627        loop {
628            match rx.recv().await {
629                Ok(event) => {
630                    // Skip high-frequency streaming variants that are UI data,
631                    // not audit-relevant. ToolCallDelta fires per-token (raw
632                    // JSON fragments) — appending each would flood the Merkle
633                    // chain + JSONL with partial-JSON Debug strings per call.
634                    if matches!(event, KernelEvent::ToolArgsDelta { .. }) {
635                        continue;
636                    }
637                    let actor = extract_agent_id(&event);
638                    let action = kernel_event_to_audit_action(&event);
639                    let resource = format!("{event:?}");
640                    audit.append(actor, action, resource);
641                }
642                Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
643                    // Surface the drop as a metric so operators can detect
644                    // incomplete audit trails instead of the events
645                    // vanishing silently (state-area F4).
646                    crate::metrics::get_metrics().audit_lagged_events.inc_by(n);
647                    tracing::warn!(
648                        skipped = n,
649                        "Audit trail subscriber lagged, skipping events"
650                    );
651                    continue;
652                }
653                Err(tokio::sync::broadcast::error::RecvError::Closed) => {
654                    tracing::info!("Audit trail event bus closed, exiting");
655                    break;
656                }
657            }
658        }
659    });
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665
666    fn sample_event(name: &str) -> KernelEvent {
667        KernelEvent::AgentCreated {
668            id: AgentId::new_v4(),
669            name: name.to_string(),
670        }
671    }
672
673    #[test]
674    fn test_event_bus_uses_sdk() {
675        let bus: EventBus = EventBus::new(256);
676        assert!(format!("{:?}", bus).contains("EventBus"));
677    }
678
679    #[tokio::test]
680    async fn test_publish_no_subscribers_ok() {
681        let bus = EventBus::new(16);
682        let result = bus.publish(sample_event("orphan"));
683        assert!(result.is_ok());
684    }
685
686    #[tokio::test]
687    async fn test_single_subscriber_receives_event() {
688        let bus = EventBus::new(16);
689        let mut rx = bus.subscribe();
690
691        let event = sample_event("test-agent");
692        bus.publish(event.clone()).unwrap();
693
694        let received = rx.try_recv().expect("should receive event");
695        match received {
696            KernelEvent::AgentCreated { name, .. } => assert_eq!(name, "test-agent"),
697            _ => panic!("wrong event type"),
698        }
699    }
700
701    #[tokio::test]
702    async fn test_multiple_subscribers_receive_events() {
703        let bus = EventBus::new(16);
704        let mut rx1 = bus.subscribe();
705        let mut rx2 = bus.subscribe();
706
707        let event = sample_event("multi");
708        bus.publish(event.clone()).unwrap();
709
710        let r1 = rx1.try_recv().expect("rx1 should receive event");
711        let r2 = rx2.try_recv().expect("rx2 should receive event");
712
713        assert!(matches!(r1, KernelEvent::AgentCreated { .. }));
714        assert!(matches!(r2, KernelEvent::AgentCreated { .. }));
715    }
716
717    #[tokio::test]
718    async fn test_kernel_event_to_audit_action() {
719        let event = KernelEvent::AgentFailed {
720            id: AgentId::new_v4(),
721            error: "boom".to_string(),
722        };
723        let action = kernel_event_to_audit_action(&event);
724        match action {
725            AuditAction::AgentExit { reason } => assert_eq!(reason, "boom"),
726            other => panic!("expected AgentExit, got {other:?}"),
727        }
728    }
729
730    // ── RFC-015 chat transparency event coverage ──
731
732    /// Round-trip JSON serialization for every new RFC-015 variant. This
733    /// guards against accidental renames that would break the WebSocket
734    /// wire format on the frontend.
735    #[test]
736    fn test_rfc015_event_round_trip_json() {
737        let cases: Vec<KernelEvent> = vec![
738            KernelEvent::ToolExecutionStarted {
739                session_id: "s1".into(),
740                tool_name: "read_file".into(),
741                tool_call_id: "call_1".into(),
742                tool_args: serde_json::json!({"path": "/src/main.rs"}),
743                context: None,
744            },
745            KernelEvent::ToolExecutionFinished {
746                session_id: "s1".into(),
747                tool_call_id: "call_1".into(),
748                tool_name: "read_file".into(),
749                duration_ms: 234,
750                is_error: false,
751                output_summary: "fn main() {}".into(),
752            },
753            KernelEvent::ToolExecutionProgress {
754                session_id: "s1".into(),
755                tool_call_id: "call_1".into(),
756                tool_name: "read_file".into(),
757                progress: "reading line 42/100".into(),
758                tab_id: None,
759                context: None,
760            },
761            KernelEvent::MemoryRecallUsed {
762                session_id: "s1".into(),
763                query: "rust errors".into(),
764                count: 3,
765                source: "warm".into(),
766            },
767            KernelEvent::TokenUsageUpdate {
768                session_id: "s1".into(),
769                input_tokens: 1234,
770                output_tokens: 567,
771            },
772            KernelEvent::ReasoningFragment {
773                session_id: "s1".into(),
774                content: "compaction done".into(),
775                source: "compaction".into(),
776            },
777        ];
778        for event in cases {
779            let json = serde_json::to_string(&event).expect("serialize");
780            let back: KernelEvent = serde_json::from_str(&json).expect("deserialize");
781            let json2 = serde_json::to_string(&back).expect("serialize round-trip");
782            assert_eq!(json, json2, "round-trip should be stable");
783        }
784    }
785
786    /// Tool progress events serialize/deserialize cleanly and round-trip
787    /// stable JSON, matching the wire format the WS layer expects.
788    #[test]
789    fn test_tool_execution_progress_serde_round_trip() {
790        let event = KernelEvent::ToolExecutionProgress {
791            session_id: "s-abc".into(),
792            tool_call_id: "call_42".into(),
793            tool_name: "browse".into(),
794            progress: "loading https://example.com".into(),
795            tab_id: Some(Uuid::new_v4()),
796            context: None,
797        };
798        let json = serde_json::to_string(&event).expect("serialize");
799        let back: KernelEvent = serde_json::from_str(&json).expect("deserialize");
800        match back {
801            KernelEvent::ToolExecutionProgress {
802                ref session_id,
803                ref tool_call_id,
804                ref tool_name,
805                ref progress,
806                tab_id,
807                ..
808            } => {
809                assert_eq!(session_id, "s-abc");
810                assert_eq!(tool_call_id, "call_42");
811                assert_eq!(tool_name, "browse");
812                assert_eq!(progress, "loading https://example.com");
813                assert!(tab_id.is_some(), "tab_id should round-trip when present");
814            }
815            other => panic!("expected ToolExecutionProgress, got {other:?}"),
816        }
817    }
818
819    /// The audit-action mapping for tool progress should produce a stable,
820    /// searchable detail string (used by the audit-trail UI to filter).
821    /// When `tab_id` is set, the detail includes `:tab=<id>`; when absent,
822    /// the original `tool_progress:<tool>` form is preserved (back-compat
823    /// for older oxi-agent versions that don't propagate tabs).
824    #[test]
825    fn test_tool_execution_progress_audit_action() {
826        let with_tab = KernelEvent::ToolExecutionProgress {
827            session_id: "s1".into(),
828            tool_call_id: "c1".into(),
829            tool_name: "browse".into(),
830            progress: "navigating".into(),
831            tab_id: Some(Uuid::new_v4()),
832            context: None,
833        };
834        match kernel_event_to_audit_action(&with_tab) {
835            AuditAction::Other { detail } => {
836                assert!(detail.contains("tool_progress"), "detail: {detail}");
837                assert!(detail.contains("browse"), "detail: {detail}");
838                assert!(
839                    detail.contains(":tab="),
840                    "detail should include tab id: {detail}"
841                );
842            }
843            other => panic!("expected Other, got {other:?}"),
844        }
845        let without_tab = KernelEvent::ToolExecutionProgress {
846            session_id: "s1".into(),
847            tool_call_id: "c1".into(),
848            tool_name: "browse".into(),
849            progress: "navigating".into(),
850            tab_id: None,
851            context: None,
852        };
853        match kernel_event_to_audit_action(&without_tab) {
854            AuditAction::Other { detail } => {
855                assert_eq!(detail, "tool_progress:browse");
856            }
857            other => panic!("expected Other, got {other:?}"),
858        }
859    }
860
861    /// `tab_id` is optional in serde (`#[serde(default)]`) so older oxi-agent
862    /// versions that don't emit it still round-trip cleanly. This guards the
863    /// backwards-compat contract explicitly.
864    #[test]
865    fn test_tool_execution_progress_tab_id_optional_in_serde() {
866        // Simulate a payload from a legacy oxi-agent (no tab_id key).
867        // KernelEvent is externally tagged, so the variant is the JSON key.
868        let legacy_json = r#"{
869            "ToolExecutionProgress": {
870                "session_id": "s-old",
871                "tool_call_id": "call_legacy",
872                "tool_name": "browse",
873                "progress": "step 1"
874            }
875        }"#;
876        let event: KernelEvent = serde_json::from_str(legacy_json).expect("deserialize legacy");
877        match &event {
878            KernelEvent::ToolExecutionProgress {
879                session_id,
880                tool_call_id,
881                tool_name,
882                progress,
883                tab_id,
884                ..
885            } => {
886                assert_eq!(session_id, "s-old");
887                assert_eq!(tool_call_id, "call_legacy");
888                assert_eq!(tool_name, "browse");
889                assert_eq!(progress, "step 1");
890                assert!(tab_id.is_none(), "missing field should default to None");
891            }
892            other => panic!("expected ToolExecutionProgress, got {other:?}"),
893        }
894        // And re-serialise — `skip_serializing_if = "Option::is_none"` keeps
895        // the wire format clean when downstream tools don't set tab_id.
896        let json = serde_json::to_string(&event).expect("serialize");
897        assert!(
898            !json.contains("tab_id"),
899            "tab_id should be omitted when None: {json}"
900        );
901    }
902
903    /// The agent_id extractor should map session-scoped RFC-015 events to
904    /// `session:<id>` for audit-trail grouping, while non-session events
905    /// keep their existing behaviour.
906    #[test]
907    fn test_rfc015_extract_agent_id() {
908        let event = KernelEvent::ToolExecutionStarted {
909            session_id: "abc-123".into(),
910            tool_name: "bash".into(),
911            tool_call_id: "c1".into(),
912            tool_args: serde_json::Value::Null,
913            context: None,
914        };
915        // The function is private; verify via the public AuditAction mapping
916        // that session-scoped events do not collide with real agent ids.
917        let action = kernel_event_to_audit_action(&event);
918        match action {
919            AuditAction::Other { detail } => {
920                assert!(
921                    detail.contains("bash"),
922                    "tool name in audit detail: {detail}"
923                );
924            }
925            other => panic!("expected Other, got {other:?}"),
926        }
927    }
928}