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
239    // ── RFC-015 Chat Transparency: lifecycle phases (P3) ────────────────
240    // Real-time events emitted by the Orchestrator as it transitions
241    // between ouroboros phases (assess → crystallize → execute → review).
242    // The web channel converts these to WS `phase` chunks; the orchestrator
243    // is the single source of truth for phase boundaries.
244    /// A lifecycle phase has begun.
245    PhaseStarted {
246        /// Session this phase belongs to.
247        session_id: String,
248        /// Phase name: `"assess"` | `"plan"` (crystallize) | `"execute"` | `"review"`.
249        phase: String,
250        /// Optional human-readable summary for the timeline header.
251        summary: Option<String>,
252    },
253    /// A lifecycle phase has completed.
254    PhaseCompleted {
255        /// Session this phase belongs to.
256        session_id: String,
257        /// Phase name — same vocabulary as `PhaseStarted::phase`.
258        phase: String,
259    },
260
261    // ── Calendar ──────────────────────────────────────────────
262    /// A calendar event was created.
263    CalendarEventCreated {
264        /// Event UID.
265        uid: String,
266        /// Event title.
267        title: String,
268        /// Start time.
269        start: String,
270        /// End time.
271        end: String,
272    },
273    /// A calendar event was updated.
274    CalendarEventUpdated {
275        /// Event UID.
276        uid: String,
277        /// Event title.
278        title: String,
279    },
280    /// A calendar event was deleted.
281    CalendarEventDeleted {
282        /// Event UID.
283        uid: String,
284        /// Event title.
285        title: String,
286    },
287    /// An email has been sent.
288    EmailSent {
289        /// Email subject.
290        subject: String,
291        /// SMTP message ID.
292        message_id: String,
293        /// Template name (if template was used/saved).
294        #[serde(default, skip_serializing_if = "Option::is_none")]
295        template_name: Option<String>,
296    },
297
298    // ── Knowledge ──────────────────────────────────────────────
299    /// A knowledge note was persisted (hook, user, or tool).
300    KnowledgePersisted {
301        session_id: String,
302        message_index: usize,
303        path: String,
304        source: String, // "hook", "user", "tool"
305    },
306    /// A knowledge note was removed by user action.
307    KnowledgeRemoved {
308        session_id: String,
309        message_index: usize,
310    },
311    /// A question was posed to the user by the agent (RFC-027, `ask_user`).
312    /// The frontend renders an input/option picker and resolves the
313    /// pending oneshot via a separate response endpoint.
314    AskUserRequest {
315        /// Unique request ID — used by the response handler to resolve
316        /// the oneshot the tool is awaiting.
317        id: String,
318        /// The question text the user sees.
319        question: String,
320        /// Optional structured options. Empty when the question is open-ended.
321        options: Vec<String>,
322    },
323    // ── Persona (agent-authored writes are security-reviewed) ───────────
324    /// A persona was created (by an agent tool, the HTTP API, or the UI).
325    PersonaCreated {
326        /// Persona ID.
327        id: String,
328        /// Persona display name.
329        name: String,
330        /// Whether it was registered enabled.
331        enabled: bool,
332        /// Origin of the change: "agent" | "api" | "ui".
333        source: String,
334    },
335    /// A persona was updated.
336    PersonaUpdated {
337        /// Persona ID.
338        id: String,
339        /// Persona display name.
340        name: String,
341        /// Origin of the change: "agent" | "api" | "ui".
342        source: String,
343    },
344}
345
346/// Convert a KernelEvent to an AuditAction for the audit trail.
347pub fn kernel_event_to_audit_action(event: &KernelEvent) -> AuditAction {
348    match event {
349        KernelEvent::AgentCreated { name, .. } => AuditAction::AgentSpawn {
350            task_type: name.clone(),
351        },
352        KernelEvent::AgentStarted { .. } => AuditAction::AgentSpawn {
353            task_type: "started".to_string(),
354        },
355        KernelEvent::AgentStopped { success, .. } => AuditAction::AgentExit {
356            reason: if *success {
357                "completed".to_string()
358            } else {
359                "stopped".to_string()
360            },
361        },
362        KernelEvent::AgentFailed { error, .. } => AuditAction::AgentExit {
363            reason: error.clone(),
364        },
365        KernelEvent::MessageReceived { content, .. } => AuditAction::Other {
366            detail: format!("message: {content}"),
367        },
368        KernelEvent::AgentOutput { output, .. } => AuditAction::Other {
369            detail: format!("agent_output:{output}"),
370        },
371        KernelEvent::ApprovalRequested {
372            id,
373            action,
374            resource,
375            ..
376        } => AuditAction::Other {
377            detail: format!("approval_requested:{id}:{action}:{resource}"),
378        },
379        KernelEvent::ApprovalResolved { id, approved } => AuditAction::Other {
380            detail: format!("approval_resolved:{id}:{approved}"),
381        },
382        KernelEvent::MemoryStored {
383            id, memory_type, ..
384        } => AuditAction::MemoryWrite {
385            entry_id: format!("{id}:{memory_type}"),
386        },
387        KernelEvent::MemoryRecalled { query, count } => AuditAction::MemoryRead {
388            entry_id: format!("query:{query}:{count}results"),
389        },
390        KernelEvent::AgentGroupCreated {
391            group_id,
392            agent_count,
393        } => AuditAction::Other {
394            detail: format!("group_created:{group_id}:{agent_count}agents"),
395        },
396        KernelEvent::AgentGroupMemberCompleted {
397            group_id,
398            agent_id,
399            success,
400        } => AuditAction::Other {
401            detail: format!("group_member_completed:{group_id}:{agent_id}:{success}"),
402        },
403        KernelEvent::ProjectCreated {
404            project_id: _,
405            name,
406            source,
407        } => AuditAction::Other {
408            detail: format!("project_created:{name}:{source}"),
409        },
410        KernelEvent::ProjectActivated {
411            project_id: _,
412            name,
413        } => AuditAction::Other {
414            detail: format!("project_activated:{name}"),
415        },
416        // ── RFC-015 ──
417        KernelEvent::ToolExecutionStarted { tool_name, .. } => AuditAction::Other {
418            detail: format!("tool_started:{tool_name}"),
419        },
420        KernelEvent::ToolExecutionFinished {
421            tool_name,
422            is_error,
423            ..
424        } => AuditAction::Other {
425            detail: format!(
426                "tool_finished:{tool_name}:{}",
427                if *is_error { "error" } else { "ok" }
428            ),
429        },
430        KernelEvent::ToolExecutionProgress {
431            tool_name,
432            tab_id,
433            context,
434            ..
435        } => AuditAction::Other {
436            detail: {
437                let mut d = format!("tool_progress:{tool_name}");
438                if let Some(id) = tab_id {
439                    d.push_str(&format!(":tab={id}"));
440                }
441                if let Some(ctx) = context
442                    .as_ref()
443                    .and_then(|c| c.get("kind"))
444                    .and_then(|k| k.as_str())
445                {
446                    d.push_str(&format!(":{ctx}"));
447                }
448                d
449            },
450        },
451        KernelEvent::MemoryRecallUsed { query, count, .. } => AuditAction::MemoryRead {
452            entry_id: format!("recall:{query}:{count}results"),
453        },
454        KernelEvent::TokenUsageUpdate {
455            input_tokens,
456            output_tokens,
457            ..
458        } => AuditAction::Other {
459            detail: format!("tokens:in={input_tokens}:out={output_tokens}"),
460        },
461        KernelEvent::ReasoningFragment { source, .. } => AuditAction::Other {
462            detail: format!("reasoning:{source}"),
463        },
464        KernelEvent::CalendarEventCreated { uid, title, .. } => AuditAction::Other {
465            detail: format!("calendar:created:{uid}:{title}"),
466        },
467        KernelEvent::CalendarEventUpdated { uid, title } => AuditAction::Other {
468            detail: format!("calendar:updated:{uid}:{title}"),
469        },
470        KernelEvent::CalendarEventDeleted { uid, title } => AuditAction::Other {
471            detail: format!("calendar:deleted:{uid}:{title}"),
472        },
473        KernelEvent::EmailSent {
474            subject,
475            message_id,
476            template_name,
477        } => AuditAction::Other {
478            detail: format!("email:sent:{subject} (msg={message_id}, tpl={template_name:?})"),
479        },
480        KernelEvent::KnowledgePersisted {
481            session_id,
482            message_index,
483            path,
484            source,
485        } => AuditAction::Other {
486            detail: format!("knowledge:persisted:{session_id}:{message_index}:{path}:{source}"),
487        },
488        KernelEvent::KnowledgeRemoved {
489            session_id,
490            message_index,
491        } => AuditAction::Other {
492            detail: format!("knowledge:removed:{session_id}:{message_index}"),
493        },
494        KernelEvent::AskUserRequest { id, question, .. } => AuditAction::Other {
495            detail: format!("ask_user:{id}:{question}"),
496        },
497        KernelEvent::PersonaCreated {
498            id, name, source, ..
499        } => AuditAction::Other {
500            detail: format!("persona:created:{id}:{name}:{source}"),
501        },
502        KernelEvent::PersonaUpdated { id, name, source } => AuditAction::Other {
503            detail: format!("persona:updated:{id}:{name}:{source}"),
504        },
505        KernelEvent::PhaseStarted { phase, .. } => AuditAction::Other {
506            detail: format!("phase_started:{phase}"),
507        },
508        KernelEvent::PhaseCompleted { phase, .. } => AuditAction::Other {
509            detail: format!("phase_completed:{phase}"),
510        },
511    }
512}
513
514/// Extract agent ID from a KernelEvent variant.
515fn extract_agent_id(event: &KernelEvent) -> String {
516    match event {
517        KernelEvent::AgentCreated { id, .. } => id.to_string(),
518        KernelEvent::AgentStarted { id, .. } => id.to_string(),
519        KernelEvent::AgentStopped { id, .. } => id.to_string(),
520        KernelEvent::AgentFailed { id, .. } => id.to_string(),
521        KernelEvent::MessageReceived { from, .. } => from.to_string(),
522        KernelEvent::AgentOutput { agent_id, .. } => agent_id.to_string(),
523        KernelEvent::AgentGroupMemberCompleted { agent_id, .. } => agent_id.to_string(),
524        KernelEvent::ProjectActivated { project_id, .. } => format!("project:{project_id}"),
525        // RFC-015: session-scoped events use session_id as the subject
526        KernelEvent::ToolExecutionStarted { session_id, .. } => format!("session:{session_id}"),
527        KernelEvent::ToolExecutionFinished { session_id, .. } => format!("session:{session_id}"),
528        KernelEvent::ToolExecutionProgress { session_id, .. } => format!("session:{session_id}"),
529        KernelEvent::MemoryRecallUsed { session_id, .. } => format!("session:{session_id}"),
530        KernelEvent::TokenUsageUpdate { session_id, .. } => format!("session:{session_id}"),
531        KernelEvent::ReasoningFragment { session_id, .. } => format!("session:{session_id}"),
532        KernelEvent::PhaseStarted { session_id, .. } => format!("session:{session_id}"),
533        KernelEvent::PhaseCompleted { session_id, .. } => format!("session:{session_id}"),
534        KernelEvent::KnowledgePersisted { session_id, .. } => format!("session:{session_id}"),
535        KernelEvent::KnowledgeRemoved { session_id, .. } => format!("session:{session_id}"),
536        _ => "system".to_string(),
537    }
538}
539
540/// Subscribe the audit trail to all kernel events.
541///
542/// The bus is broadcast-based; this spawns a long-running task that
543/// forwards every event into the audit trail as a structured entry.
544/// Lagged subscribers are logged and recovered.
545pub fn attach_audit_trail(bus: &EventBus, audit: Arc<AuditTrail>) {
546    let mut rx = bus.subscribe();
547    tokio::spawn(async move {
548        loop {
549            match rx.recv().await {
550                Ok(event) => {
551                    let actor = extract_agent_id(&event);
552                    let action = kernel_event_to_audit_action(&event);
553                    let resource = format!("{event:?}");
554                    audit.append(actor, action, resource);
555                }
556                Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
557                    // Surface the drop as a metric so operators can detect
558                    // incomplete audit trails instead of the events
559                    // vanishing silently (state-area F4).
560                    crate::metrics::get_metrics().audit_lagged_events.inc_by(n);
561                    tracing::warn!(
562                        skipped = n,
563                        "Audit trail subscriber lagged, skipping events"
564                    );
565                    continue;
566                }
567                Err(tokio::sync::broadcast::error::RecvError::Closed) => {
568                    tracing::info!("Audit trail event bus closed, exiting");
569                    break;
570                }
571            }
572        }
573    });
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579
580    fn sample_event(name: &str) -> KernelEvent {
581        KernelEvent::AgentCreated {
582            id: AgentId::new_v4(),
583            name: name.to_string(),
584        }
585    }
586
587    #[test]
588    fn test_event_bus_uses_sdk() {
589        let bus: EventBus = EventBus::new(256);
590        assert!(format!("{:?}", bus).contains("EventBus"));
591    }
592
593    #[tokio::test]
594    async fn test_publish_no_subscribers_ok() {
595        let bus = EventBus::new(16);
596        let result = bus.publish(sample_event("orphan"));
597        assert!(result.is_ok());
598    }
599
600    #[tokio::test]
601    async fn test_single_subscriber_receives_event() {
602        let bus = EventBus::new(16);
603        let mut rx = bus.subscribe();
604
605        let event = sample_event("test-agent");
606        bus.publish(event.clone()).unwrap();
607
608        let received = rx.try_recv().expect("should receive event");
609        match received {
610            KernelEvent::AgentCreated { name, .. } => assert_eq!(name, "test-agent"),
611            _ => panic!("wrong event type"),
612        }
613    }
614
615    #[tokio::test]
616    async fn test_multiple_subscribers_receive_events() {
617        let bus = EventBus::new(16);
618        let mut rx1 = bus.subscribe();
619        let mut rx2 = bus.subscribe();
620
621        let event = sample_event("multi");
622        bus.publish(event.clone()).unwrap();
623
624        let r1 = rx1.try_recv().expect("rx1 should receive event");
625        let r2 = rx2.try_recv().expect("rx2 should receive event");
626
627        assert!(matches!(r1, KernelEvent::AgentCreated { .. }));
628        assert!(matches!(r2, KernelEvent::AgentCreated { .. }));
629    }
630
631    #[tokio::test]
632    async fn test_kernel_event_to_audit_action() {
633        let event = KernelEvent::AgentFailed {
634            id: AgentId::new_v4(),
635            error: "boom".to_string(),
636        };
637        let action = kernel_event_to_audit_action(&event);
638        match action {
639            AuditAction::AgentExit { reason } => assert_eq!(reason, "boom"),
640            other => panic!("expected AgentExit, got {other:?}"),
641        }
642    }
643
644    // ── RFC-015 chat transparency event coverage ──
645
646    /// Round-trip JSON serialization for every new RFC-015 variant. This
647    /// guards against accidental renames that would break the WebSocket
648    /// wire format on the frontend.
649    #[test]
650    fn test_rfc015_event_round_trip_json() {
651        let cases: Vec<KernelEvent> = vec![
652            KernelEvent::ToolExecutionStarted {
653                session_id: "s1".into(),
654                tool_name: "read_file".into(),
655                tool_call_id: "call_1".into(),
656                tool_args: serde_json::json!({"path": "/src/main.rs"}),
657                context: None,
658            },
659            KernelEvent::ToolExecutionFinished {
660                session_id: "s1".into(),
661                tool_call_id: "call_1".into(),
662                tool_name: "read_file".into(),
663                duration_ms: 234,
664                is_error: false,
665                output_summary: "fn main() {}".into(),
666            },
667            KernelEvent::ToolExecutionProgress {
668                session_id: "s1".into(),
669                tool_call_id: "call_1".into(),
670                tool_name: "read_file".into(),
671                progress: "reading line 42/100".into(),
672                tab_id: None,
673                context: None,
674            },
675            KernelEvent::MemoryRecallUsed {
676                session_id: "s1".into(),
677                query: "rust errors".into(),
678                count: 3,
679                source: "warm".into(),
680            },
681            KernelEvent::TokenUsageUpdate {
682                session_id: "s1".into(),
683                input_tokens: 1234,
684                output_tokens: 567,
685            },
686            KernelEvent::ReasoningFragment {
687                session_id: "s1".into(),
688                content: "compaction done".into(),
689                source: "compaction".into(),
690            },
691        ];
692        for event in cases {
693            let json = serde_json::to_string(&event).expect("serialize");
694            let back: KernelEvent = serde_json::from_str(&json).expect("deserialize");
695            let json2 = serde_json::to_string(&back).expect("serialize round-trip");
696            assert_eq!(json, json2, "round-trip should be stable");
697        }
698    }
699
700    /// Tool progress events serialize/deserialize cleanly and round-trip
701    /// stable JSON, matching the wire format the WS layer expects.
702    #[test]
703    fn test_tool_execution_progress_serde_round_trip() {
704        let event = KernelEvent::ToolExecutionProgress {
705            session_id: "s-abc".into(),
706            tool_call_id: "call_42".into(),
707            tool_name: "browse".into(),
708            progress: "loading https://example.com".into(),
709            tab_id: Some(Uuid::new_v4()),
710            context: None,
711        };
712        let json = serde_json::to_string(&event).expect("serialize");
713        let back: KernelEvent = serde_json::from_str(&json).expect("deserialize");
714        match back {
715            KernelEvent::ToolExecutionProgress {
716                ref session_id,
717                ref tool_call_id,
718                ref tool_name,
719                ref progress,
720                tab_id,
721                ..
722            } => {
723                assert_eq!(session_id, "s-abc");
724                assert_eq!(tool_call_id, "call_42");
725                assert_eq!(tool_name, "browse");
726                assert_eq!(progress, "loading https://example.com");
727                assert!(tab_id.is_some(), "tab_id should round-trip when present");
728            }
729            other => panic!("expected ToolExecutionProgress, got {other:?}"),
730        }
731    }
732
733    /// The audit-action mapping for tool progress should produce a stable,
734    /// searchable detail string (used by the audit-trail UI to filter).
735    /// When `tab_id` is set, the detail includes `:tab=<id>`; when absent,
736    /// the original `tool_progress:<tool>` form is preserved (back-compat
737    /// for older oxi-agent versions that don't propagate tabs).
738    #[test]
739    fn test_tool_execution_progress_audit_action() {
740        let with_tab = KernelEvent::ToolExecutionProgress {
741            session_id: "s1".into(),
742            tool_call_id: "c1".into(),
743            tool_name: "browse".into(),
744            progress: "navigating".into(),
745            tab_id: Some(Uuid::new_v4()),
746            context: None,
747        };
748        match kernel_event_to_audit_action(&with_tab) {
749            AuditAction::Other { detail } => {
750                assert!(detail.contains("tool_progress"), "detail: {detail}");
751                assert!(detail.contains("browse"), "detail: {detail}");
752                assert!(
753                    detail.contains(":tab="),
754                    "detail should include tab id: {detail}"
755                );
756            }
757            other => panic!("expected Other, got {other:?}"),
758        }
759        let without_tab = KernelEvent::ToolExecutionProgress {
760            session_id: "s1".into(),
761            tool_call_id: "c1".into(),
762            tool_name: "browse".into(),
763            progress: "navigating".into(),
764            tab_id: None,
765            context: None,
766        };
767        match kernel_event_to_audit_action(&without_tab) {
768            AuditAction::Other { detail } => {
769                assert_eq!(detail, "tool_progress:browse");
770            }
771            other => panic!("expected Other, got {other:?}"),
772        }
773    }
774
775    /// `tab_id` is optional in serde (`#[serde(default)]`) so older oxi-agent
776    /// versions that don't emit it still round-trip cleanly. This guards the
777    /// backwards-compat contract explicitly.
778    #[test]
779    fn test_tool_execution_progress_tab_id_optional_in_serde() {
780        // Simulate a payload from a legacy oxi-agent (no tab_id key).
781        // KernelEvent is externally tagged, so the variant is the JSON key.
782        let legacy_json = r#"{
783            "ToolExecutionProgress": {
784                "session_id": "s-old",
785                "tool_call_id": "call_legacy",
786                "tool_name": "browse",
787                "progress": "step 1"
788            }
789        }"#;
790        let event: KernelEvent = serde_json::from_str(legacy_json).expect("deserialize legacy");
791        match &event {
792            KernelEvent::ToolExecutionProgress {
793                session_id,
794                tool_call_id,
795                tool_name,
796                progress,
797                tab_id,
798                ..
799            } => {
800                assert_eq!(session_id, "s-old");
801                assert_eq!(tool_call_id, "call_legacy");
802                assert_eq!(tool_name, "browse");
803                assert_eq!(progress, "step 1");
804                assert!(tab_id.is_none(), "missing field should default to None");
805            }
806            other => panic!("expected ToolExecutionProgress, got {other:?}"),
807        }
808        // And re-serialise — `skip_serializing_if = "Option::is_none"` keeps
809        // the wire format clean when downstream tools don't set tab_id.
810        let json = serde_json::to_string(&event).expect("serialize");
811        assert!(
812            !json.contains("tab_id"),
813            "tab_id should be omitted when None: {json}"
814        );
815    }
816
817    /// The agent_id extractor should map session-scoped RFC-015 events to
818    /// `session:<id>` for audit-trail grouping, while non-session events
819    /// keep their existing behaviour.
820    #[test]
821    fn test_rfc015_extract_agent_id() {
822        let event = KernelEvent::ToolExecutionStarted {
823            session_id: "abc-123".into(),
824            tool_name: "bash".into(),
825            tool_call_id: "c1".into(),
826            tool_args: serde_json::Value::Null,
827            context: None,
828        };
829        // The function is private; verify via the public AuditAction mapping
830        // that session-scoped events do not collide with real agent ids.
831        let action = kernel_event_to_audit_action(&event);
832        match action {
833            AuditAction::Other { detail } => {
834                assert!(
835                    detail.contains("bash"),
836                    "tool name in audit detail: {detail}"
837                );
838            }
839            other => panic!("expected Other, got {other:?}"),
840        }
841    }
842}