1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "lowercase")]
19pub enum CompactionReason {
20 Manual,
22 Threshold,
24 Overflow,
26}
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(tag = "source")]
31pub enum SummarizationSource {
32 #[serde(rename = "branchSummary")]
34 BranchSummary,
35 #[serde(rename = "compaction")]
37 Compaction {
38 reason: CompactionReason,
40 },
41}
42
43#[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 AgentStart,
53 #[serde(rename_all = "camelCase")]
55 AgentEnd {
56 messages: Vec<AgentMessage>,
58 will_retry: bool,
60 },
61 AgentSettled {
63 aborted: bool,
65 },
66 TurnStart,
68 #[serde(rename_all = "camelCase")]
70 TurnEnd {
71 message: AgentMessage,
73 #[serde(with = "tagged_tool_results")]
75 tool_results: Vec<ToolResultMessage>,
76 },
77 MessageStart {
79 message: AgentMessage,
81 },
82 #[serde(rename_all = "camelCase")]
84 MessageUpdate {
85 message: AgentMessage,
87 assistant_message_event: AssistantMessageEvent,
89 },
90 MessageEnd {
92 message: AgentMessage,
94 },
95 #[serde(rename_all = "camelCase")]
97 ToolExecutionStart {
98 tool_call_id: String,
100 tool_name: String,
102 args: JsonObject,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 parent_tool_call_id: Option<String>,
107 },
108 #[serde(rename_all = "camelCase")]
110 ToolExecutionUpdate {
111 tool_call_id: String,
113 tool_name: String,
115 args: JsonObject,
117 partial_result: AgentToolResult,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 parent_tool_call_id: Option<String>,
122 },
123 #[serde(rename_all = "camelCase")]
125 ToolExecutionEnd {
126 tool_call_id: String,
128 tool_name: String,
130 result: AgentToolResult,
132 is_error: bool,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 duration_ms: Option<u64>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 parent_tool_call_id: Option<String>,
140 },
141 #[serde(rename_all = "camelCase")]
143 QueueUpdate {
144 steering: Vec<String>,
146 follow_up: Vec<String>,
148 },
149 CompactionStart {
151 reason: CompactionReason,
153 },
154 #[serde(rename_all = "camelCase")]
156 CompactionEnd {
157 reason: CompactionReason,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 result: Option<CompactionResult>,
162 aborted: bool,
164 will_retry: bool,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
168 error_message: Option<String>,
169 },
170 EntryAppended {
172 entry: SessionEntry,
174 },
175 SessionInfoChanged {
177 #[serde(default, skip_serializing_if = "Option::is_none")]
179 name: Option<String>,
180 },
181 ThinkingLevelChanged {
183 level: ThinkingLevel,
185 },
186 #[serde(rename_all = "camelCase")]
188 AutoRetryStart {
189 attempt: u32,
191 max_attempts: u32,
193 delay_ms: u64,
195 error_message: String,
197 },
198 #[serde(rename_all = "camelCase")]
200 AutoRetryEnd {
201 success: bool,
203 attempt: u32,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 final_error: Option<String>,
208 },
209 #[serde(rename_all = "camelCase")]
211 SummarizationRetryScheduled {
212 attempt: u32,
214 max_attempts: u32,
216 delay_ms: u64,
218 error_message: String,
220 },
221 SummarizationRetryAttemptStart {
223 #[serde(flatten)]
225 source: SummarizationSource,
226 },
227 SummarizationRetryFinished,
229 BashExecutionUpdate {
231 #[serde(default, skip_serializing_if = "Option::is_none")]
233 id: Option<String>,
234 delta: String,
236 },
237}
238
239impl AgentSessionEvent {
240 #[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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
308#[serde(rename_all = "camelCase")]
309pub struct ExtensionErrorEvent {
310 pub extension_path: String,
312 pub event: String,
314 pub error: String,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub stack: Option<String>,
319}
320
321#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
323#[serde(rename_all = "lowercase")]
324pub enum ReplaceReason {
325 New,
327 Resume,
329 Fork,
331 Clone,
333 Import,
335}
336
337#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "lowercase")]
340pub enum ModelChangeSource {
341 Set,
343 Cycle,
345 Restore,
347 Extension,
349}
350
351#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(rename_all = "lowercase")]
354pub enum RunState {
355 Idle,
357 Running,
359 Compacting,
361 Retrying,
363 Waiting,
365}
366
367#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
369#[serde(rename_all = "lowercase")]
370pub enum ExtensionDisabledReason {
371 Trap,
373 Panic,
375 Disabled,
377}
378
379#[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 #[serde(rename_all = "camelCase")]
389 SessionReplaced {
390 #[serde(default, skip_serializing_if = "Option::is_none")]
392 session_file: Option<String>,
393 session_id: String,
395 reason: ReplaceReason,
397 },
398 #[serde(rename_all = "camelCase")]
400 ModelChanged {
401 model: Model,
403 #[serde(default, skip_serializing_if = "Option::is_none")]
405 routed_model: Option<Model>,
406 source: ModelChangeSource,
408 },
409 #[serde(rename_all = "camelCase")]
411 RunState {
412 state: RunState,
414 #[serde(default, skip_serializing_if = "Option::is_none")]
416 waiting_on: Option<Vec<String>>,
417 },
418 ToolsChanged {
420 active: Vec<String>,
422 },
423 CommandsChanged,
425 ExtensionUiDismiss {
427 id: String,
429 reason: DismissReason,
431 },
432 Lagged {
434 dropped: u64,
436 },
437 #[serde(rename = "oino_ui_patch", rename_all = "camelCase")]
440 UiPatch {
441 scope: oino_ui::slot::Scope,
443 #[serde(default, skip_serializing_if = "Option::is_none")]
445 session_id: Option<String>,
446 rev: u64,
448 ops: Vec<oino_ui::slot::RegistryOp>,
450 },
451 Topic {
453 topic: String,
455 data: serde_json::Value,
457 sticky: bool,
459 publisher: String,
461 },
462 AgentsChanged {
464 agents: Vec<crate::agents::AgentInfo>,
466 },
467 #[serde(rename_all = "camelCase")]
469 AgentEvent {
470 agent_id: String,
472 event: Box<ClientEvent>,
474 },
475 ShutdownRequested,
477 #[serde(rename_all = "camelCase")]
481 ExtensionDisabled {
482 session_id: String,
484 extension_id: String,
486 reason: ExtensionDisabledReason,
488 },
489 #[serde(rename_all = "camelCase")]
493 SessionCrashed {
494 error: String,
496 },
497 #[serde(rename_all = "camelCase")]
501 SessionTakenOver {
502 by: oino_session::LeaseInfo,
504 },
505 #[serde(rename_all = "camelCase")]
510 SessionEvicted {
511 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 #[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#[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(AgentSessionEvent),
569 Ui(UiRequest),
571 ExtensionError(TaggedExtensionError),
573 Oino(OinoEvent),
575}
576
577#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
579#[serde(tag = "type", rename = "extension_error")]
580pub struct TaggedExtensionError {
581 #[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
604mod 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}