Skip to main content

oino_core/
events.rs

1//! Client events (runtime.md §6): `AgentSessionEvent` plus the other client events.
2//!
3//! These types will move to `oino-protocol::events` once that crate exists; the JSON shapes
4//! are already the wire shapes (fixed field names, `type` tags).
5
6use oino_agent::{AgentEvent, AgentToolResult};
7use oino_ai::{
8    AgentMessage, AssistantMessageEvent, JsonObject, Model, ThinkingLevel, ToolResultMessage,
9};
10use oino_session::SessionEntry;
11use serde::{Deserialize, Serialize};
12
13use crate::compaction::CompactionResult;
14use crate::ui::{DismissReason, UiRequest};
15
16/// Why compaction ran.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "lowercase")]
19pub enum CompactionReason {
20    /// `/compact`, RPC or an extension.
21    Manual,
22    /// Context crossed the threshold.
23    Threshold,
24    /// The provider reported a context overflow.
25    Overflow,
26}
27
28/// What a summarisation retry belongs to.
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(tag = "source")]
31pub enum SummarizationSource {
32    /// A branch summary.
33    #[serde(rename = "branchSummary")]
34    BranchSummary,
35    /// A compaction.
36    #[serde(rename = "compaction")]
37    Compaction {
38        /// Compaction trigger.
39        reason: CompactionReason,
40    },
41}
42
43/// One event emitted by an agent session.
44#[allow(
45    clippy::large_enum_variant,
46    reason = "variants mirror the event shapes; events are moved through channels one at a time"
47)]
48#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
49#[serde(tag = "type", rename_all = "snake_case")]
50pub enum AgentSessionEvent {
51    /// A run started.
52    AgentStart,
53    /// A run ended.
54    #[serde(rename_all = "camelCase")]
55    AgentEnd {
56        /// Messages the run added.
57        messages: Vec<AgentMessage>,
58        /// An automatic retry follows.
59        will_retry: bool,
60    },
61    /// The session became idle after activity.
62    AgentSettled {
63        /// The activity was aborted.
64        aborted: bool,
65    },
66    /// A turn started.
67    TurnStart,
68    /// A turn ended.
69    #[serde(rename_all = "camelCase")]
70    TurnEnd {
71        /// Assistant message of the turn.
72        message: AgentMessage,
73        /// Tool results (serialised with `role`).
74        #[serde(with = "tagged_tool_results")]
75        tool_results: Vec<ToolResultMessage>,
76    },
77    /// A message started.
78    MessageStart {
79        /// The message.
80        message: AgentMessage,
81    },
82    /// The streaming message changed.
83    #[serde(rename_all = "camelCase")]
84    MessageUpdate {
85        /// Partial message.
86        message: AgentMessage,
87        /// Provider event.
88        assistant_message_event: AssistantMessageEvent,
89    },
90    /// A message is final.
91    MessageEnd {
92        /// The message.
93        message: AgentMessage,
94    },
95    /// A tool call started.
96    #[serde(rename_all = "camelCase")]
97    ToolExecutionStart {
98        /// Tool-call id.
99        tool_call_id: String,
100        /// Tool name.
101        tool_name: String,
102        /// Arguments.
103        args: JsonObject,
104        /// Calling tool for nested calls.
105        #[serde(default, skip_serializing_if = "Option::is_none")]
106        parent_tool_call_id: Option<String>,
107    },
108    /// A tool published a partial result.
109    #[serde(rename_all = "camelCase")]
110    ToolExecutionUpdate {
111        /// Tool-call id.
112        tool_call_id: String,
113        /// Tool name.
114        tool_name: String,
115        /// Arguments.
116        args: JsonObject,
117        /// Partial result.
118        partial_result: AgentToolResult,
119        /// Calling tool for nested calls.
120        #[serde(default, skip_serializing_if = "Option::is_none")]
121        parent_tool_call_id: Option<String>,
122    },
123    /// A tool call settled.
124    #[serde(rename_all = "camelCase")]
125    ToolExecutionEnd {
126        /// Tool-call id.
127        tool_call_id: String,
128        /// Tool name.
129        tool_name: String,
130        /// Result.
131        result: AgentToolResult,
132        /// Error flag.
133        is_error: bool,
134        /// Wall time.
135        #[serde(default, skip_serializing_if = "Option::is_none")]
136        duration_ms: Option<u64>,
137        /// Calling tool for nested calls.
138        #[serde(default, skip_serializing_if = "Option::is_none")]
139        parent_tool_call_id: Option<String>,
140    },
141    /// Complete steering / follow-up queues.
142    #[serde(rename_all = "camelCase")]
143    QueueUpdate {
144        /// Steering texts.
145        steering: Vec<String>,
146        /// Follow-up texts.
147        follow_up: Vec<String>,
148    },
149    /// Compaction started.
150    CompactionStart {
151        /// Trigger.
152        reason: CompactionReason,
153    },
154    /// Compaction finished.
155    #[serde(rename_all = "camelCase")]
156    CompactionEnd {
157        /// Trigger.
158        reason: CompactionReason,
159        /// Result (absent on failure).
160        #[serde(default, skip_serializing_if = "Option::is_none")]
161        result: Option<CompactionResult>,
162        /// Cancelled.
163        aborted: bool,
164        /// The interrupted turn is retried.
165        will_retry: bool,
166        /// Failure text.
167        #[serde(default, skip_serializing_if = "Option::is_none")]
168        error_message: Option<String>,
169    },
170    /// An extension appended an entry.
171    EntryAppended {
172        /// The entry.
173        entry: SessionEntry,
174    },
175    /// The session name changed (absent = cleared).
176    SessionInfoChanged {
177        /// New name.
178        #[serde(default, skip_serializing_if = "Option::is_none")]
179        name: Option<String>,
180    },
181    /// The thinking level changed.
182    ThinkingLevelChanged {
183        /// New level.
184        level: ThinkingLevel,
185    },
186    /// An automatic retry is scheduled.
187    #[serde(rename_all = "camelCase")]
188    AutoRetryStart {
189        /// Attempt number (1-based).
190        attempt: u32,
191        /// Retry budget.
192        max_attempts: u32,
193        /// Backoff delay.
194        delay_ms: u64,
195        /// Error that triggered the retry.
196        error_message: String,
197    },
198    /// Automatic retries ended.
199    #[serde(rename_all = "camelCase")]
200    AutoRetryEnd {
201        /// A retry succeeded.
202        success: bool,
203        /// Last attempt number.
204        attempt: u32,
205        /// Final error.
206        #[serde(default, skip_serializing_if = "Option::is_none")]
207        final_error: Option<String>,
208    },
209    /// A summarisation retry is scheduled.
210    #[serde(rename_all = "camelCase")]
211    SummarizationRetryScheduled {
212        /// Attempt number.
213        attempt: u32,
214        /// Retry budget.
215        max_attempts: u32,
216        /// Backoff delay.
217        delay_ms: u64,
218        /// Error that triggered the retry.
219        error_message: String,
220    },
221    /// A summarisation retry attempt started.
222    SummarizationRetryAttemptStart {
223        /// What is being summarised.
224        #[serde(flatten)]
225        source: SummarizationSource,
226    },
227    /// Summarisation retries ended.
228    SummarizationRetryFinished,
229    /// Streaming output of a `!` command.
230    BashExecutionUpdate {
231        /// Caller-supplied id.
232        #[serde(default, skip_serializing_if = "Option::is_none")]
233        id: Option<String>,
234        /// Appended output.
235        delta: String,
236    },
237}
238
239impl AgentSessionEvent {
240    /// Maps a loop event to its session event (`agent_end` needs `will_retry`).
241    #[must_use]
242    pub fn from_agent(event: AgentEvent, will_retry: bool) -> Self {
243        match event {
244            AgentEvent::AgentStart => Self::AgentStart,
245            AgentEvent::AgentEnd { messages } => Self::AgentEnd {
246                messages,
247                will_retry,
248            },
249            AgentEvent::TurnStart => Self::TurnStart,
250            AgentEvent::TurnEnd {
251                message,
252                tool_results,
253            } => Self::TurnEnd {
254                message,
255                tool_results,
256            },
257            AgentEvent::MessageStart { message } => Self::MessageStart { message },
258            AgentEvent::MessageUpdate {
259                message,
260                assistant_message_event,
261            } => Self::MessageUpdate {
262                message,
263                assistant_message_event,
264            },
265            AgentEvent::MessageEnd { message } => Self::MessageEnd { message },
266            AgentEvent::ToolExecutionStart {
267                tool_call_id,
268                tool_name,
269                args,
270            } => Self::ToolExecutionStart {
271                tool_call_id,
272                tool_name,
273                args,
274                parent_tool_call_id: None,
275            },
276            AgentEvent::ToolExecutionUpdate {
277                tool_call_id,
278                tool_name,
279                args,
280                partial_result,
281            } => Self::ToolExecutionUpdate {
282                tool_call_id,
283                tool_name,
284                args,
285                partial_result,
286                parent_tool_call_id: None,
287            },
288            AgentEvent::ToolExecutionEnd {
289                tool_call_id,
290                tool_name,
291                result,
292                is_error,
293                duration_ms,
294            } => Self::ToolExecutionEnd {
295                tool_call_id,
296                tool_name,
297                result,
298                is_error,
299                duration_ms,
300                parent_tool_call_id: None,
301            },
302        }
303    }
304}
305
306/// Extension error as a client event (`extension_error`).
307#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
308#[serde(rename_all = "camelCase")]
309pub struct ExtensionErrorEvent {
310    /// Extension id/path or a `<runtime>`-style marker.
311    pub extension_path: String,
312    /// Event or call that failed.
313    pub event: String,
314    /// Error text.
315    pub error: String,
316    /// Stack trace, when known.
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub stack: Option<String>,
319}
320
321/// Why a session was replaced.
322#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
323#[serde(rename_all = "lowercase")]
324pub enum ReplaceReason {
325    /// New session.
326    New,
327    /// Switched to another file.
328    Resume,
329    /// Forked from an entry.
330    Fork,
331    /// Cloned at the leaf.
332    Clone,
333    /// Imported JSONL.
334    Import,
335}
336
337/// What changed the model.
338#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "lowercase")]
340pub enum ModelChangeSource {
341    /// `set_model`.
342    Set,
343    /// Cycling.
344    Cycle,
345    /// Restored from the session or defaults.
346    Restore,
347    /// An extension.
348    Extension,
349}
350
351/// Coarse run state for frontends.
352#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(rename_all = "lowercase")]
354pub enum RunState {
355    /// Nothing runs.
356    Idle,
357    /// An agent run is active.
358    Running,
359    /// Compaction or branch summary.
360    Compacting,
361    /// Waiting before an automatic retry.
362    Retrying,
363    /// Idle, but a wait source reports work in flight.
364    Waiting,
365}
366
367/// Why an extension was disabled for one session (X-TRAP, core spec §2.3).
368#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
369#[serde(rename_all = "lowercase")]
370pub enum ExtensionDisabledReason {
371    /// The guest trapped.
372    Trap,
373    /// A Rust-side panic inside the instance call.
374    Panic,
375    /// Disabled explicitly (user toggle, process-level breaker).
376    Disabled,
377}
378
379/// Oino client events (runtime §6.2).
380#[allow(
381    clippy::large_enum_variant,
382    reason = "events are moved through channels one at a time"
383)]
384#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
385#[serde(tag = "type", rename_all = "snake_case")]
386pub enum OinoEvent {
387    /// The runtime replaced the session; clients reset view state.
388    #[serde(rename_all = "camelCase")]
389    SessionReplaced {
390        /// New session file.
391        #[serde(default, skip_serializing_if = "Option::is_none")]
392        session_file: Option<String>,
393        /// New session id.
394        session_id: String,
395        /// Why.
396        reason: ReplaceReason,
397    },
398    /// The selected model changed.
399    #[serde(rename_all = "camelCase")]
400    ModelChanged {
401        /// Selected model.
402        model: Model,
403        /// Physical model under a virtual selection.
404        #[serde(default, skip_serializing_if = "Option::is_none")]
405        routed_model: Option<Model>,
406        /// What changed it.
407        source: ModelChangeSource,
408    },
409    /// Coarse run state.
410    #[serde(rename_all = "camelCase")]
411    RunState {
412        /// State.
413        state: RunState,
414        /// Wait reasons for `waiting`.
415        #[serde(default, skip_serializing_if = "Option::is_none")]
416        waiting_on: Option<Vec<String>>,
417    },
418    /// The active tools changed.
419    ToolsChanged {
420        /// Active tool names.
421        active: Vec<String>,
422    },
423    /// Registered commands changed; refetch `get_commands`.
424    CommandsChanged,
425    /// A dialog closed (`extension_ui_dismiss`); other frontends close it too.
426    ExtensionUiDismiss {
427        /// Request id.
428        id: String,
429        /// Why it closed.
430        reason: DismissReason,
431    },
432    /// Events were dropped for a slow subscriber.
433    Lagged {
434        /// Number of dropped events.
435        dropped: u64,
436    },
437    /// A registry patch (`ui-protocol` §9.2): `{"type":"oino_ui_patch", …UiPatch}`.
438    /// Frontends apply the ops in order; `rev != last + 1` triggers `_oino/ui_resync`.
439    #[serde(rename = "oino_ui_patch", rename_all = "camelCase")]
440    UiPatch {
441        /// Registry scope (`session` / `host`).
442        scope: oino_ui::slot::Scope,
443        /// Session id (session scope only).
444        #[serde(default, skip_serializing_if = "Option::is_none")]
445        session_id: Option<String>,
446        /// `previous + 1`.
447        rev: u64,
448        /// Ops, applied in order.
449        ops: Vec<oino_ui::slot::RegistryOp>,
450    },
451    /// A bus topic the frontend subscribed to (`_oino/topics`) was published.
452    Topic {
453        /// Topic name.
454        topic: String,
455        /// Payload.
456        data: serde_json::Value,
457        /// Published sticky.
458        sticky: bool,
459        /// Publishing extension (`oino.core` for core topics).
460        publisher: String,
461    },
462    /// The nested agents of the session tree changed (spawned, closed, state).
463    AgentsChanged {
464        /// Every open agent of the tree (`_oino/agents` shape).
465        agents: Vec<crate::agents::AgentInfo>,
466    },
467    /// A client event of a nested agent (only while `_oino/agents{follow: true}`).
468    #[serde(rename_all = "camelCase")]
469    AgentEvent {
470        /// Agent id (the child session id).
471        agent_id: String,
472        /// The child's event.
473        event: Box<ClientEvent>,
474    },
475    /// An extension called `ctx.shutdown()`; the frontend exits once the session is idle.
476    ShutdownRequested,
477    /// An extension was disabled for one session after a trap or panic (X-TRAP): it stops
478    /// dispatching for that session only; other sessions keep it. Frontends surface it as
479    /// a notice ("Extension X crashed and was disabled for this session").
480    #[serde(rename_all = "camelCase")]
481    ExtensionDisabled {
482        /// Session the extension is disabled for.
483        session_id: String,
484        /// Disabled extension id.
485        extension_id: String,
486        /// Why it was disabled.
487        reason: ExtensionDisabledReason,
488    },
489    /// A session task crashed and was contained (X-PANIC): the run book was reset and the
490    /// session stays usable; other sessions are unaffected. Frontends surface it as a
491    /// notice and can re-prompt.
492    #[serde(rename_all = "camelCase")]
493    SessionCrashed {
494        /// Panic message.
495        error: String,
496    },
497    /// Another runner took the current session over (D-TAKEOVER): this runtime closed the
498    /// session and released it; frontends show "Session moved to another window" and return
499    /// to their session list / next session.
500    #[serde(rename_all = "camelCase")]
501    SessionTakenOver {
502        /// Who took the session over.
503        by: oino_session::LeaseInfo,
504    },
505    /// The hub evicted an idle session (E-LAZY): the session was disposed and its lease
506    /// released; the record stays on disk and the next message re-opens and repairs it.
507    /// Published on the session's stream before it ends — only subscribers still attached
508    /// see it (eviction requires none); the hub list is the source of truth.
509    #[serde(rename_all = "camelCase")]
510    SessionEvicted {
511        /// Evicted session id.
512        session_id: String,
513    },
514}
515
516impl From<crate::topics::TopicValue> for OinoEvent {
517    fn from(value: crate::topics::TopicValue) -> Self {
518        Self::Topic {
519            topic: value.topic,
520            data: value.data,
521            sticky: value.sticky,
522            publisher: value.publisher,
523        }
524    }
525}
526
527impl From<oino_ui::slot::UiPatch> for OinoEvent {
528    fn from(patch: oino_ui::slot::UiPatch) -> Self {
529        Self::UiPatch {
530            scope: patch.scope,
531            session_id: patch.session_id,
532            rev: patch.rev,
533            ops: patch.ops,
534        }
535    }
536}
537
538impl OinoEvent {
539    /// The registry patch this event carries (`oino_ui_patch`), if any.
540    #[must_use]
541    pub fn ui_patch(&self) -> Option<oino_ui::slot::UiPatch> {
542        match self {
543            Self::UiPatch {
544                scope,
545                session_id,
546                rev,
547                ops,
548            } => Some(oino_ui::slot::UiPatch {
549                scope: *scope,
550                session_id: session_id.clone(),
551                rev: *rev,
552                ops: ops.clone(),
553            }),
554            _ => None,
555        }
556    }
557}
558
559/// Every event a client receives, in one ordered stream.
560#[allow(
561    clippy::large_enum_variant,
562    reason = "events are moved through channels one at a time"
563)]
564#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
565#[serde(untagged)]
566pub enum ClientEvent {
567    /// Session event.
568    Session(AgentSessionEvent),
569    /// Extension UI request (`extension_ui_request`).
570    Ui(UiRequest),
571    /// Extension failure (`extension_error`).
572    ExtensionError(TaggedExtensionError),
573    /// Oino addition.
574    Oino(OinoEvent),
575}
576
577/// [`ExtensionErrorEvent`] with its `type: "extension_error"` tag.
578#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
579#[serde(tag = "type", rename = "extension_error")]
580pub struct TaggedExtensionError {
581    /// The error.
582    #[serde(flatten)]
583    pub error: ExtensionErrorEvent,
584}
585
586impl From<AgentSessionEvent> for ClientEvent {
587    fn from(event: AgentSessionEvent) -> Self {
588        Self::Session(event)
589    }
590}
591
592impl From<OinoEvent> for ClientEvent {
593    fn from(event: OinoEvent) -> Self {
594        Self::Oino(event)
595    }
596}
597
598impl From<ExtensionErrorEvent> for ClientEvent {
599    fn from(error: ExtensionErrorEvent) -> Self {
600        Self::ExtensionError(TaggedExtensionError { error })
601    }
602}
603
604/// Serialises tool results as `{"role":"toolResult",…}`.
605mod tagged_tool_results {
606    use oino_ai::{AgentMessage, ToolResultMessage};
607    use serde::{Deserialize, Deserializer, Serialize, Serializer};
608
609    pub(super) fn serialize<S: Serializer>(
610        results: &[ToolResultMessage],
611        serializer: S,
612    ) -> Result<S::Ok, S::Error> {
613        let tagged: Vec<AgentMessage> = results
614            .iter()
615            .cloned()
616            .map(AgentMessage::ToolResult)
617            .collect();
618        tagged.serialize(serializer)
619    }
620
621    pub(super) fn deserialize<'de, D: Deserializer<'de>>(
622        deserializer: D,
623    ) -> Result<Vec<ToolResultMessage>, D::Error> {
624        let values = Vec::<serde_json::Value>::deserialize(deserializer)?;
625        values
626            .into_iter()
627            .map(|mut value| {
628                if let Some(object) = value.as_object_mut() {
629                    object.shift_remove("role");
630                }
631                serde_json::from_value(value).map_err(serde::de::Error::custom)
632            })
633            .collect()
634    }
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640    use serde_json::json;
641
642    #[test]
643    fn wire_shapes() {
644        let event = ClientEvent::Session(AgentSessionEvent::SummarizationRetryAttemptStart {
645            source: SummarizationSource::Compaction {
646                reason: CompactionReason::Overflow,
647            },
648        });
649        assert_eq!(
650            serde_json::to_value(&event).unwrap(),
651            json!({"type":"summarization_retry_attempt_start","source":"compaction","reason":"overflow"})
652        );
653        let error: ClientEvent = ExtensionErrorEvent {
654            extension_path: "x".into(),
655            event: "input".into(),
656            error: "boom".into(),
657            stack: None,
658        }
659        .into();
660        assert_eq!(
661            serde_json::to_value(&error).unwrap(),
662            json!({"type":"extension_error","extensionPath":"x","event":"input","error":"boom"})
663        );
664        assert_eq!(
665            serde_json::to_value(ClientEvent::Session(AgentSessionEvent::QueueUpdate {
666                steering: vec!["a".into()],
667                follow_up: vec![]
668            }))
669            .unwrap(),
670            json!({"type":"queue_update","steering":["a"],"followUp":[]})
671        );
672        let disabled: ClientEvent = OinoEvent::ExtensionDisabled {
673            session_id: "s1".into(),
674            extension_id: "acme.x".into(),
675            reason: ExtensionDisabledReason::Trap,
676        }
677        .into();
678        assert_eq!(
679            serde_json::to_value(&disabled).unwrap(),
680            json!({"type":"extension_disabled","sessionId":"s1","extensionId":"acme.x","reason":"trap"})
681        );
682        let crashed: ClientEvent = OinoEvent::SessionCrashed {
683            error: "boom".into(),
684        }
685        .into();
686        assert_eq!(
687            serde_json::to_value(&crashed).unwrap(),
688            json!({"type":"session_crashed","error":"boom"})
689        );
690        let evicted: ClientEvent = OinoEvent::SessionEvicted {
691            session_id: "s1".into(),
692        }
693        .into();
694        assert_eq!(
695            serde_json::to_value(&evicted).unwrap(),
696            json!({"type":"session_evicted","sessionId":"s1"})
697        );
698    }
699}