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::observability::{AuditAction, AuditTrail};
15use oxi_sdk::EventBus as SdkEventBus;
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    AgentStopped {
46        /// The agent's ID.
47        id: AgentId,
48    },
49    /// An agent has encountered a failure.
50    AgentFailed {
51        /// The agent's ID.
52        id: AgentId,
53        /// Description of the error.
54        error: String,
55    },
56    /// A message has been received from an agent.
57    MessageReceived {
58        /// The sending agent's ID.
59        from: AgentId,
60        /// Message content.
61        content: String,
62    },
63    /// A new seed has been created.
64    SeedCreated {
65        /// The seed's ID.
66        seed_id: uuid::Uuid,
67    },
68    /// An evaluation has completed.
69    EvaluationComplete {
70        /// The seed that was evaluated.
71        seed_id: uuid::Uuid,
72        /// Whether the evaluation passed.
73        passed: bool,
74    },
75    /// An Ouroboros phase has started.
76    PhaseStarted {
77        /// The session this phase belongs to.
78        session_id: String,
79        /// The phase that started.
80        phase: oxios_ouroboros::Phase,
81    },
82    /// An Ouroboros phase has completed.
83    PhaseCompleted {
84        /// The session this phase belongs to.
85        session_id: String,
86        /// The phase that completed.
87        phase: oxios_ouroboros::Phase,
88        /// A brief summary of the result.
89        result_summary: String,
90    },
91    /// An agent has produced output.
92    AgentOutput {
93        /// The session this output belongs to.
94        session_id: String,
95        /// The agent's ID.
96        agent_id: AgentId,
97        /// The output content.
98        output: String,
99    },
100    /// A HitL approval request has been submitted.
101    ApprovalRequested {
102        /// The approval request ID.
103        id: uuid::Uuid,
104        /// The action requiring approval.
105        action: String,
106        /// The resource involved.
107        resource: String,
108        /// Reason for the request.
109        reason: String,
110    },
111    /// A HitL approval has been resolved (approved or rejected).
112    ApprovalResolved {
113        /// The approval request ID.
114        id: uuid::Uuid,
115        /// Whether it was approved (true) or rejected (false).
116        approved: bool,
117    },
118    /// A memory entry was stored.
119    MemoryStored {
120        /// Memory entry ID.
121        id: String,
122        /// Memory type label.
123        memory_type: String,
124        /// Source of the memory.
125        source: String,
126    },
127    /// Memories were recalled for a new session.
128    MemoryRecalled {
129        /// The recall query.
130        query: String,
131        /// Number of memories returned.
132        count: usize,
133    },
134    /// Multi-agent group created.
135    AgentGroupCreated {
136        /// The group's ID.
137        group_id: uuid::Uuid,
138        /// Number of agents in the group.
139        agent_count: usize,
140    },
141    /// An agent in a group completed.
142    AgentGroupMemberCompleted {
143        /// The group's ID.
144        group_id: uuid::Uuid,
145        /// The agent's ID.
146        agent_id: uuid::Uuid,
147        /// Whether the agent succeeded.
148        success: bool,
149    },
150    /// A new Project has been created (RFC-011).
151    ProjectCreated {
152        /// The project's ID.
153        project_id: uuid::Uuid,
154        /// The project's name.
155        name: String,
156        /// How it was created.
157        source: String,
158    },
159    /// A Project has been activated (RFC-011).
160    ProjectActivated {
161        /// The project's ID.
162        project_id: uuid::Uuid,
163        /// The project's name.
164        name: String,
165    },
166    /// Evolution has started (evaluate → evolve → re-execute loop).
167    EvolutionStarted {
168        /// Seed ID before evolution.
169        seed_id: uuid::Uuid,
170        /// Seed ID after evolution.
171        new_seed_id: uuid::Uuid,
172        /// Current iteration (0-based).
173        iteration: u32,
174    },
175    /// Evolution loop reached max iterations.
176    EvolutionMaxReached {
177        /// The final seed ID.
178        seed_id: uuid::Uuid,
179        /// Final evaluation score.
180        final_score: f64,
181        /// Number of iterations completed.
182        iterations: u32,
183    },
184
185    // ── RFC-015 Chat Transparency ─────────────────────────────
186    // Real-time events emitted by AgentRuntime during tool execution
187    // and streaming. Web channel converts these to WS chunks.
188    /// A tool execution has started (real-time, RFC-015).
189    ToolExecutionStarted {
190        /// Session this tool call belongs to.
191        session_id: String,
192        /// Name of the tool (e.g. "read_file", "bash", "memory_recall").
193        tool_name: String,
194        /// Provider-specific tool call ID used to correlate start/end.
195        tool_call_id: String,
196        /// Tool input arguments (JSON).
197        tool_args: serde_json::Value,
198    },
199    /// A tool execution has finished (real-time, RFC-015).
200    ToolExecutionFinished {
201        /// Session this tool call belongs to.
202        session_id: String,
203        /// Provider-specific tool call ID.
204        tool_call_id: String,
205        /// Name of the tool.
206        tool_name: String,
207        /// Wall-clock duration in milliseconds.
208        duration_ms: u64,
209        /// Whether the tool returned an error.
210        is_error: bool,
211        /// Truncated output (max ~500 chars) for streaming.
212        output_summary: String,
213    },
214    /// A tool execution emitted a progress update (real-time, RFC-015).
215    ToolExecutionProgress {
216        /// Session this tool call belongs to.
217        session_id: String,
218        /// Provider-specific tool call ID.
219        tool_call_id: String,
220        /// Name of the tool.
221        tool_name: String,
222        /// Human-readable progress text (already-formatted by the tool).
223        progress: String,
224        /// Tab that emitted this progress event, if the upstream tool tracks
225        /// tabs. `None` for tools that don't have a tab concept (e.g. legacy
226        /// oxi-agent versions that don't propagate `tab_id`).
227        #[serde(default, skip_serializing_if = "Option::is_none")]
228        tab_id: Option<Uuid>,
229        /// Semantic context from the tool call (e.g. PageVisit, WebSearch).
230        /// Stored as `serde_json::Value` to decouple kernel events from
231        /// oxi-sdk's internal `ToolCallContext` enum. UI consumers that
232        /// understand a context variant render it richly; older consumers
233        /// simply ignore the field.
234        #[serde(default, skip_serializing_if = "Option::is_none")]
235        context: Option<serde_json::Value>,
236    },
237    /// Memory was recalled during agent execution (RFC-015).
238    MemoryRecallUsed {
239        /// Session this recall belongs to.
240        session_id: String,
241        /// The recall query.
242        query: String,
243        /// Number of memories returned.
244        count: usize,
245        /// Memory tier source ("hot" | "warm" | "cold").
246        source: String,
247    },
248    /// Token usage update (RFC-015).
249    TokenUsageUpdate {
250        /// Session this usage belongs to.
251        session_id: String,
252        /// Cumulative input tokens.
253        input_tokens: u64,
254        /// Cumulative output tokens.
255        output_tokens: u64,
256    },
257    /// Reasoning/compaction fragment (RFC-015).
258    ReasoningFragment {
259        /// Session this fragment belongs to.
260        session_id: String,
261        /// The fragment text (chain-of-thought, compaction summary, etc).
262        content: String,
263        /// Source label: "chain_of_thought" | "compaction" | "reflection".
264        source: String,
265    },
266
267    // ── Calendar ──────────────────────────────────────────────
268    /// A calendar event was created.
269    CalendarEventCreated {
270        /// Event UID.
271        uid: String,
272        /// Event title.
273        title: String,
274        /// Start time.
275        start: String,
276        /// End time.
277        end: String,
278    },
279    /// A calendar event was updated.
280    CalendarEventUpdated {
281        /// Event UID.
282        uid: String,
283        /// Event title.
284        title: String,
285    },
286    /// A calendar event was deleted.
287    CalendarEventDeleted {
288        /// Event UID.
289        uid: String,
290        /// Event title.
291        title: String,
292    },
293    /// An email has been sent.
294    EmailSent {
295        /// Email subject.
296        subject: String,
297        /// SMTP message ID.
298        message_id: String,
299        /// Template name (if template was used/saved).
300        #[serde(default, skip_serializing_if = "Option::is_none")]
301        template_name: Option<String>,
302    },
303}
304
305/// Convert a KernelEvent to an AuditAction for the audit trail.
306pub fn kernel_event_to_audit_action(event: &KernelEvent) -> AuditAction {
307    match event {
308        KernelEvent::AgentCreated { name, .. } => AuditAction::AgentSpawn {
309            task_type: name.clone(),
310        },
311        KernelEvent::AgentStarted { .. } => AuditAction::AgentSpawn {
312            task_type: "started".to_string(),
313        },
314        KernelEvent::AgentStopped { .. } => AuditAction::AgentExit {
315            reason: "stopped".to_string(),
316        },
317        KernelEvent::AgentFailed { error, .. } => AuditAction::AgentExit {
318            reason: error.clone(),
319        },
320        KernelEvent::MessageReceived { content, .. } => AuditAction::Other {
321            detail: format!("message: {content}"),
322        },
323        KernelEvent::SeedCreated { seed_id, .. } => AuditAction::Other {
324            detail: format!("seed_created:{seed_id}"),
325        },
326        KernelEvent::EvaluationComplete { seed_id, passed } => AuditAction::Other {
327            detail: format!("evaluation:{seed_id}:{passed}"),
328        },
329        KernelEvent::PhaseStarted { session_id, phase } => AuditAction::Other {
330            detail: format!("phase_started:{session_id}:{phase}"),
331        },
332        KernelEvent::PhaseCompleted {
333            session_id,
334            phase,
335            result_summary,
336        } => AuditAction::Other {
337            detail: format!("phase_completed:{session_id}:{phase}:{result_summary}"),
338        },
339        KernelEvent::AgentOutput { output, .. } => AuditAction::Other {
340            detail: format!("agent_output:{output}"),
341        },
342        KernelEvent::ApprovalRequested {
343            id,
344            action,
345            resource,
346            reason: _,
347        } => AuditAction::Other {
348            detail: format!("approval_requested:{id}:{action}:{resource}"),
349        },
350        KernelEvent::ApprovalResolved { id, approved } => AuditAction::Other {
351            detail: format!("approval_resolved:{id}:{approved}"),
352        },
353        KernelEvent::MemoryStored {
354            id, memory_type, ..
355        } => AuditAction::MemoryWrite {
356            entry_id: format!("{id}:{memory_type}"),
357        },
358        KernelEvent::MemoryRecalled { query, count } => AuditAction::MemoryRead {
359            entry_id: format!("query:{query}:{count}results"),
360        },
361        KernelEvent::AgentGroupCreated {
362            group_id,
363            agent_count,
364        } => AuditAction::Other {
365            detail: format!("group_created:{group_id}:{agent_count}agents"),
366        },
367        KernelEvent::AgentGroupMemberCompleted {
368            group_id,
369            agent_id,
370            success,
371        } => AuditAction::Other {
372            detail: format!("group_member_completed:{group_id}:{agent_id}:{success}"),
373        },
374        KernelEvent::EvolutionStarted {
375            seed_id,
376            new_seed_id,
377            iteration,
378        } => AuditAction::Other {
379            detail: format!("evolution:{seed_id}->{new_seed_id}:iter{iteration}"),
380        },
381        KernelEvent::EvolutionMaxReached {
382            seed_id,
383            final_score,
384            iterations,
385        } => AuditAction::Other {
386            detail: format!("evolution_max:{seed_id}:score={final_score}:iters={iterations}"),
387        },
388        KernelEvent::ProjectCreated {
389            project_id: _,
390            name,
391            source,
392        } => AuditAction::Other {
393            detail: format!("project_created:{name}:{source}"),
394        },
395        KernelEvent::ProjectActivated {
396            project_id: _,
397            name,
398        } => AuditAction::Other {
399            detail: format!("project_activated:{name}"),
400        },
401        // ── RFC-015 ──
402        KernelEvent::ToolExecutionStarted { tool_name, .. } => AuditAction::Other {
403            detail: format!("tool_started:{tool_name}"),
404        },
405        KernelEvent::ToolExecutionFinished {
406            tool_name,
407            is_error,
408            ..
409        } => AuditAction::Other {
410            detail: format!(
411                "tool_finished:{tool_name}:{}",
412                if *is_error { "error" } else { "ok" }
413            ),
414        },
415        KernelEvent::ToolExecutionProgress {
416            tool_name,
417            tab_id,
418            context,
419            ..
420        } => AuditAction::Other {
421            detail: {
422                let mut d = format!("tool_progress:{tool_name}");
423                if let Some(id) = tab_id {
424                    d.push_str(&format!(":tab={id}"));
425                }
426                if let Some(ctx) = context
427                    .as_ref()
428                    .and_then(|c| c.get("kind"))
429                    .and_then(|k| k.as_str())
430                {
431                    d.push_str(&format!(":{ctx}"));
432                }
433                d
434            },
435        },
436        KernelEvent::MemoryRecallUsed { query, count, .. } => AuditAction::MemoryRead {
437            entry_id: format!("recall:{query}:{count}results"),
438        },
439        KernelEvent::TokenUsageUpdate {
440            input_tokens,
441            output_tokens,
442            ..
443        } => AuditAction::Other {
444            detail: format!("tokens:in={input_tokens}:out={output_tokens}"),
445        },
446        KernelEvent::ReasoningFragment { source, .. } => AuditAction::Other {
447            detail: format!("reasoning:{source}"),
448        },
449        KernelEvent::CalendarEventCreated { uid, title, .. } => AuditAction::Other {
450            detail: format!("calendar:created:{uid}:{title}"),
451        },
452        KernelEvent::CalendarEventUpdated { uid, title } => AuditAction::Other {
453            detail: format!("calendar:updated:{uid}:{title}"),
454        },
455        KernelEvent::CalendarEventDeleted { uid, title } => AuditAction::Other {
456            detail: format!("calendar:deleted:{uid}:{title}"),
457        },
458        KernelEvent::EmailSent {
459            subject,
460            message_id,
461            template_name,
462        } => AuditAction::Other {
463            detail: format!("email:sent:{subject} (msg={message_id}, tpl={template_name:?})"),
464        },
465    }
466}
467
468/// Extract agent ID from a KernelEvent variant.
469fn extract_agent_id(event: &KernelEvent) -> String {
470    match event {
471        KernelEvent::AgentCreated { id, .. } => id.to_string(),
472        KernelEvent::AgentStarted { id, .. } => id.to_string(),
473        KernelEvent::AgentStopped { id, .. } => id.to_string(),
474        KernelEvent::AgentFailed { id, .. } => id.to_string(),
475        KernelEvent::MessageReceived { from, .. } => from.to_string(),
476        KernelEvent::AgentOutput { agent_id, .. } => agent_id.to_string(),
477        KernelEvent::AgentGroupMemberCompleted { agent_id, .. } => agent_id.to_string(),
478        KernelEvent::ProjectActivated { project_id, .. } => format!("project:{project_id}"),
479        // RFC-015: session-scoped events use session_id as the subject
480        KernelEvent::ToolExecutionStarted { session_id, .. } => format!("session:{session_id}"),
481        KernelEvent::ToolExecutionFinished { session_id, .. } => format!("session:{session_id}"),
482        KernelEvent::ToolExecutionProgress { session_id, .. } => format!("session:{session_id}"),
483        KernelEvent::MemoryRecallUsed { session_id, .. } => format!("session:{session_id}"),
484        KernelEvent::TokenUsageUpdate { session_id, .. } => format!("session:{session_id}"),
485        KernelEvent::ReasoningFragment { session_id, .. } => format!("session:{session_id}"),
486        _ => "system".to_string(),
487    }
488}
489
490/// Subscribe the audit trail to all kernel events.
491///
492/// The bus is broadcast-based; this spawns a long-running task that
493/// forwards every event into the audit trail as a structured entry.
494/// Lagged subscribers are logged and recovered.
495pub fn attach_audit_trail(bus: &EventBus, audit: Arc<AuditTrail>) {
496    let mut rx = bus.subscribe();
497    tokio::spawn(async move {
498        loop {
499            match rx.recv().await {
500                Ok(event) => {
501                    let actor = extract_agent_id(&event);
502                    let action = kernel_event_to_audit_action(&event);
503                    let resource = format!("{event:?}");
504                    audit.append(actor, action, resource);
505                }
506                Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
507                    tracing::warn!(
508                        skipped = n,
509                        "Audit trail subscriber lagged, skipping events"
510                    );
511                    continue;
512                }
513                Err(tokio::sync::broadcast::error::RecvError::Closed) => {
514                    tracing::info!("Audit trail event bus closed, exiting");
515                    break;
516                }
517            }
518        }
519    });
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    fn sample_event(name: &str) -> KernelEvent {
527        KernelEvent::AgentCreated {
528            id: AgentId::new_v4(),
529            name: name.to_string(),
530        }
531    }
532
533    #[test]
534    fn test_event_bus_uses_sdk() {
535        let bus: EventBus = EventBus::new(256);
536        assert!(format!("{:?}", bus).contains("EventBus"));
537    }
538
539    #[tokio::test]
540    async fn test_publish_no_subscribers_ok() {
541        let bus = EventBus::new(16);
542        let result = bus.publish(sample_event("orphan"));
543        assert!(result.is_ok());
544    }
545
546    #[tokio::test]
547    async fn test_single_subscriber_receives_event() {
548        let bus = EventBus::new(16);
549        let mut rx = bus.subscribe();
550
551        let event = sample_event("test-agent");
552        bus.publish(event.clone()).unwrap();
553
554        let received = rx.try_recv().expect("should receive event");
555        match received {
556            KernelEvent::AgentCreated { name, .. } => assert_eq!(name, "test-agent"),
557            _ => panic!("wrong event type"),
558        }
559    }
560
561    #[tokio::test]
562    async fn test_multiple_subscribers_receive_events() {
563        let bus = EventBus::new(16);
564        let mut rx1 = bus.subscribe();
565        let mut rx2 = bus.subscribe();
566
567        let event = sample_event("multi");
568        bus.publish(event.clone()).unwrap();
569
570        let r1 = rx1.try_recv().expect("rx1 should receive event");
571        let r2 = rx2.try_recv().expect("rx2 should receive event");
572
573        assert!(matches!(r1, KernelEvent::AgentCreated { .. }));
574        assert!(matches!(r2, KernelEvent::AgentCreated { .. }));
575    }
576
577    #[tokio::test]
578    async fn test_kernel_event_to_audit_action() {
579        let event = KernelEvent::AgentFailed {
580            id: AgentId::new_v4(),
581            error: "boom".to_string(),
582        };
583        let action = kernel_event_to_audit_action(&event);
584        match action {
585            AuditAction::AgentExit { reason } => assert_eq!(reason, "boom"),
586            other => panic!("expected AgentExit, got {other:?}"),
587        }
588    }
589
590    // ── RFC-015 chat transparency event coverage ──
591
592    /// Round-trip JSON serialization for every new RFC-015 variant. This
593    /// guards against accidental renames that would break the WebSocket
594    /// wire format on the frontend.
595    #[test]
596    fn test_rfc015_event_round_trip_json() {
597        let cases: Vec<KernelEvent> = vec![
598            KernelEvent::ToolExecutionStarted {
599                session_id: "s1".into(),
600                tool_name: "read_file".into(),
601                tool_call_id: "call_1".into(),
602                tool_args: serde_json::json!({"path": "/src/main.rs"}),
603            },
604            KernelEvent::ToolExecutionFinished {
605                session_id: "s1".into(),
606                tool_call_id: "call_1".into(),
607                tool_name: "read_file".into(),
608                duration_ms: 234,
609                is_error: false,
610                output_summary: "fn main() {}".into(),
611            },
612            KernelEvent::ToolExecutionProgress {
613                session_id: "s1".into(),
614                tool_call_id: "call_1".into(),
615                tool_name: "read_file".into(),
616                progress: "reading line 42/100".into(),
617                tab_id: None,
618                context: None,
619            },
620            KernelEvent::MemoryRecallUsed {
621                session_id: "s1".into(),
622                query: "rust errors".into(),
623                count: 3,
624                source: "warm".into(),
625            },
626            KernelEvent::TokenUsageUpdate {
627                session_id: "s1".into(),
628                input_tokens: 1234,
629                output_tokens: 567,
630            },
631            KernelEvent::ReasoningFragment {
632                session_id: "s1".into(),
633                content: "compaction done".into(),
634                source: "compaction".into(),
635            },
636        ];
637        for event in cases {
638            let json = serde_json::to_string(&event).expect("serialize");
639            let back: KernelEvent = serde_json::from_str(&json).expect("deserialize");
640            let json2 = serde_json::to_string(&back).expect("serialize round-trip");
641            assert_eq!(json, json2, "round-trip should be stable");
642        }
643    }
644
645    /// Tool progress events serialize/deserialize cleanly and round-trip
646    /// stable JSON, matching the wire format the WS layer expects.
647    #[test]
648    fn test_tool_execution_progress_serde_round_trip() {
649        let event = KernelEvent::ToolExecutionProgress {
650            session_id: "s-abc".into(),
651            tool_call_id: "call_42".into(),
652            tool_name: "browse".into(),
653            progress: "loading https://example.com".into(),
654            tab_id: Some(Uuid::new_v4()),
655            context: None,
656        };
657        let json = serde_json::to_string(&event).expect("serialize");
658        let back: KernelEvent = serde_json::from_str(&json).expect("deserialize");
659        match back {
660            KernelEvent::ToolExecutionProgress {
661                ref session_id,
662                ref tool_call_id,
663                ref tool_name,
664                ref progress,
665                tab_id,
666                ..
667            } => {
668                assert_eq!(session_id, "s-abc");
669                assert_eq!(tool_call_id, "call_42");
670                assert_eq!(tool_name, "browse");
671                assert_eq!(progress, "loading https://example.com");
672                assert!(tab_id.is_some(), "tab_id should round-trip when present");
673            }
674            other => panic!("expected ToolExecutionProgress, got {other:?}"),
675        }
676    }
677
678    /// The audit-action mapping for tool progress should produce a stable,
679    /// searchable detail string (used by the audit-trail UI to filter).
680    /// When `tab_id` is set, the detail includes `:tab=<id>`; when absent,
681    /// the original `tool_progress:<tool>` form is preserved (back-compat
682    /// for older oxi-agent versions that don't propagate tabs).
683    #[test]
684    fn test_tool_execution_progress_audit_action() {
685        let with_tab = KernelEvent::ToolExecutionProgress {
686            session_id: "s1".into(),
687            tool_call_id: "c1".into(),
688            tool_name: "browse".into(),
689            progress: "navigating".into(),
690            tab_id: Some(Uuid::new_v4()),
691            context: None,
692        };
693        match kernel_event_to_audit_action(&with_tab) {
694            AuditAction::Other { detail } => {
695                assert!(detail.contains("tool_progress"), "detail: {detail}");
696                assert!(detail.contains("browse"), "detail: {detail}");
697                assert!(
698                    detail.contains(":tab="),
699                    "detail should include tab id: {detail}"
700                );
701            }
702            other => panic!("expected Other, got {other:?}"),
703        }
704        let without_tab = KernelEvent::ToolExecutionProgress {
705            session_id: "s1".into(),
706            tool_call_id: "c1".into(),
707            tool_name: "browse".into(),
708            progress: "navigating".into(),
709            tab_id: None,
710            context: None,
711        };
712        match kernel_event_to_audit_action(&without_tab) {
713            AuditAction::Other { detail } => {
714                assert_eq!(detail, "tool_progress:browse");
715            }
716            other => panic!("expected Other, got {other:?}"),
717        }
718    }
719
720    /// `tab_id` is optional in serde (`#[serde(default)]`) so older oxi-agent
721    /// versions that don't emit it still round-trip cleanly. This guards the
722    /// backwards-compat contract explicitly.
723    #[test]
724    fn test_tool_execution_progress_tab_id_optional_in_serde() {
725        // Simulate a payload from a legacy oxi-agent (no tab_id key).
726        // KernelEvent is externally tagged, so the variant is the JSON key.
727        let legacy_json = r#"{
728            "ToolExecutionProgress": {
729                "session_id": "s-old",
730                "tool_call_id": "call_legacy",
731                "tool_name": "browse",
732                "progress": "step 1"
733            }
734        }"#;
735        let event: KernelEvent = serde_json::from_str(legacy_json).expect("deserialize legacy");
736        match &event {
737            KernelEvent::ToolExecutionProgress {
738                session_id,
739                tool_call_id,
740                tool_name,
741                progress,
742                tab_id,
743                ..
744            } => {
745                assert_eq!(session_id, "s-old");
746                assert_eq!(tool_call_id, "call_legacy");
747                assert_eq!(tool_name, "browse");
748                assert_eq!(progress, "step 1");
749                assert!(tab_id.is_none(), "missing field should default to None");
750            }
751            other => panic!("expected ToolExecutionProgress, got {other:?}"),
752        }
753        // And re-serialise — `skip_serializing_if = "Option::is_none"` keeps
754        // the wire format clean when downstream tools don't set tab_id.
755        let json = serde_json::to_string(&event).expect("serialize");
756        assert!(
757            !json.contains("tab_id"),
758            "tab_id should be omitted when None: {json}"
759        );
760    }
761
762    /// The agent_id extractor should map session-scoped RFC-015 events to
763    /// `session:<id>` for audit-trail grouping, while non-session events
764    /// keep their existing behaviour.
765    #[test]
766    fn test_rfc015_extract_agent_id() {
767        let event = KernelEvent::ToolExecutionStarted {
768            session_id: "abc-123".into(),
769            tool_name: "bash".into(),
770            tool_call_id: "c1".into(),
771            tool_args: serde_json::Value::Null,
772        };
773        // The function is private; verify via the public AuditAction mapping
774        // that session-scoped events do not collide with real agent ids.
775        let action = kernel_event_to_audit_action(&event);
776        match action {
777            AuditAction::Other { detail } => {
778                assert!(
779                    detail.contains("bash"),
780                    "tool name in audit detail: {detail}"
781                );
782            }
783            other => panic!("expected Other, got {other:?}"),
784        }
785    }
786}