Skip to main content

horus/protocol/
mod.rs

1//! The small event protocol shared by agent frontends.
2
3use serde::Deserialize;
4use serde::Deserializer;
5use serde::Serialize;
6
7pub use self::replay::events as replay_events;
8pub(crate) use self::replay::{
9    ATTACHMENTS_FIELD, CONTEXT_COMPACTED_MARKER, INTERNAL_MESSAGE_FIELD, REPLAY_REASONING_FIELD,
10    TOOL_ERROR_FIELD, internal_message_kind, is_internal_message, strip_attachment_references,
11};
12
13mod replay;
14
15/// Maximum total UTF-8 bytes accepted in one user-input submission.
16pub const MAX_USER_INPUT_BYTES: usize = 1024 * 1024;
17
18/// Maximum UTF-8 bytes accepted in capability command input or queued active input.
19pub const MAX_CAPABILITY_INPUT_BYTES: usize = 64 * 1024;
20
21/// One immutable, session-bound file addressed by an opaque reference.
22///
23/// Only upload-origin references are valid in `Op::UserInput.attachments`.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct SessionFileReference {
27    pub id: String,
28    pub name: String,
29    pub size: u64,
30    pub media_type: String,
31}
32
33/// A command submitted by a frontend.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct Submission {
36    /// Correlates all events produced by this command.
37    pub id: String,
38    /// Command payload.
39    pub op: Op,
40}
41
42/// Frontend-visible context for the session owner, workspace, and origin.
43///
44/// These values are correlation metadata, not authentication or authorization.
45/// A remote host must derive them after authentication and inject tenant-scoped
46/// backends when it creates the agent.
47#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
48pub struct SessionContext {
49    /// Opaque tenant or organization identifier.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub tenant_id: Option<String>,
52    /// Opaque identifier for the user who owns the session.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub user_id: Option<String>,
55    /// Optional display label, such as the local operating-system user name.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub user_name: Option<String>,
58    /// Opaque workspace identifier; this is not a filesystem path.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub workspace_id: Option<String>,
61    /// Optional frontend-facing workspace label.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub workspace_label: Option<String>,
64    /// Optional label describing what created the session.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub origin_label: Option<String>,
67}
68
69/// Commands supported by the agent.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(tag = "type", rename_all = "snake_case")]
72#[non_exhaustive]
73pub enum Op {
74    /// Start a user turn.
75    UserInput {
76        text: String,
77        attachments: Vec<SessionFileReference>,
78    },
79    /// Submit capability-owned input while a turn is active.
80    ActiveInput {
81        operation: String,
82        turn_id: String,
83        text: String,
84    },
85    /// Abort one active turn.
86    Interrupt { turn_id: String },
87    /// Resolve a paused tool batch.
88    ExecApproval {
89        id: String,
90        decision: ReviewDecision,
91    },
92    /// Invokes a command owned by one capability.
93    CapabilityCommand {
94        capability: String,
95        command: String,
96        arguments: String,
97        /// Optional caller-editable text kept separate from routing arguments.
98        ///
99        /// When embedded in a frontend action, a present value is its caller-editable text.
100        #[serde(deserialize_with = "required_option")]
101        input: Option<String>,
102        #[serde(deserialize_with = "required_option")]
103        target: Option<MessageTarget>,
104    },
105    /// Selects one immutable registered model route.
106    SetModel { route: String },
107    /// Requests that the frontend reopen an existing session.
108    ResumeSession { session_id: String },
109}
110
111fn required_option<'de, D, T>(deserializer: D) -> std::result::Result<Option<T>, D::Error>
112where
113    D: Deserializer<'de>,
114    T: Deserialize<'de>,
115{
116    Option::deserialize(deserializer)
117}
118
119/// An event emitted to a frontend.
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub struct Event {
122    /// Submission ID that caused this event, if it was command-driven.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub submission_id: Option<String>,
125    /// Event payload.
126    pub msg: EventMsg,
127}
128
129/// Events supported by the minimal frontend contract.
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131#[serde(tag = "type", rename_all = "snake_case")]
132#[non_exhaustive]
133pub enum EventMsg {
134    Error(ErrorEvent),
135    Warning(WarningEvent),
136    SessionConfigured(SessionConfiguredEvent),
137    #[serde(rename = "task_started")]
138    TurnStarted(TurnStartedEvent),
139    #[serde(rename = "task_complete")]
140    TurnComplete(TurnCompleteEvent),
141    TurnAborted(TurnAbortedEvent),
142    UserMessage(UserMessageEvent),
143    AgentMessage(AgentMessageEvent),
144    AgentMessageContentDelta(AgentMessageContentDeltaEvent),
145    AgentReasoningContentDelta(AgentReasoningContentDeltaEvent),
146    ModelStepStarted(ModelStepStartedEvent),
147    ModelStepCompleted(ModelStepCompletedEvent),
148    SessionHistory(SessionHistoryEvent),
149    ModelChanged(ModelChangedEvent),
150    SessionResumeRequested(SessionResumeRequestedEvent),
151    ToolCallBegin(ToolCallBeginEvent),
152    ToolCallEnd(ToolCallEndEvent),
153    ExecApprovalRequest(ExecApprovalRequestEvent),
154    ExecApprovalReview(ExecApprovalReviewEvent),
155    TokenCount(TokenCountEvent),
156    ContextCompacted,
157    WebSearchBegin(WebSearchBeginEvent),
158    WebSearchEnd(WebSearchEndEvent),
159    Frontend(FrontendEvent),
160}
161
162/// Provider-neutral streaming output before submission correlation is attached.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub enum ModelEvent {
165    TextDelta(String),
166    CommentaryDelta(String),
167    ReasoningDelta(String),
168    WebSearchStarted {
169        call_id: String,
170    },
171    WebSearchCompleted {
172        call_id: String,
173        action: WebSearchAction,
174    },
175}
176
177/// Provider-neutral action reported by hosted web search.
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(tag = "type", rename_all = "snake_case")]
180pub enum WebSearchAction {
181    Search {
182        queries: Vec<String>,
183    },
184    OpenPage {
185        url: Option<String>,
186    },
187    FindInPage {
188        url: Option<String>,
189        pattern: Option<String>,
190    },
191    Interrupted,
192    Other,
193}
194
195impl ModelEvent {
196    /// Converts one normalized provider event into the frontend protocol.
197    #[must_use]
198    pub fn into_event(self, session_id: &str, turn_id: &str, model_step_id: &str) -> EventMsg {
199        match self {
200            Self::TextDelta(delta) => {
201                EventMsg::AgentMessageContentDelta(AgentMessageContentDeltaEvent {
202                    session_id: session_id.into(),
203                    turn_id: turn_id.into(),
204                    model_step_id: model_step_id.into(),
205                    delta,
206                    phase: AgentMessagePhase::FinalAnswer,
207                })
208            }
209            Self::CommentaryDelta(delta) => {
210                EventMsg::AgentMessageContentDelta(AgentMessageContentDeltaEvent {
211                    session_id: session_id.into(),
212                    turn_id: turn_id.into(),
213                    model_step_id: model_step_id.into(),
214                    delta,
215                    phase: AgentMessagePhase::Commentary,
216                })
217            }
218            Self::ReasoningDelta(delta) => {
219                EventMsg::AgentReasoningContentDelta(AgentReasoningContentDeltaEvent {
220                    session_id: session_id.into(),
221                    turn_id: turn_id.into(),
222                    model_step_id: model_step_id.into(),
223                    delta,
224                })
225            }
226            Self::WebSearchStarted { call_id } => EventMsg::WebSearchBegin(WebSearchBeginEvent {
227                session_id: session_id.into(),
228                turn_id: turn_id.into(),
229                model_step_id: model_step_id.into(),
230                call_id,
231            }),
232            Self::WebSearchCompleted { call_id, action } => {
233                EventMsg::WebSearchEnd(WebSearchEndEvent {
234                    session_id: session_id.into(),
235                    turn_id: turn_id.into(),
236                    model_step_id: model_step_id.into(),
237                    call_id,
238                    action,
239                })
240            }
241        }
242    }
243}
244
245/// A frontend command declared by a capability.
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247pub struct FrontendCommand {
248    pub name: String,
249    pub arguments: String,
250    pub description: String,
251}
252
253/// UI metadata exported by one capability.
254#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
255pub struct FrontendContribution {
256    pub capability: String,
257    /// Whether the composed runtime installs session-bound file attachment endpoints.
258    pub accepts_file_attachments: bool,
259    /// Optional capability-owned item count for generic summaries.
260    pub count: Option<usize>,
261    pub commands: Vec<FrontendCommand>,
262    pub widgets: Vec<FrontendWidget>,
263    pub references: Vec<FrontendReference>,
264    pub active_input: Option<FrontendActiveInput>,
265}
266
267/// One middleware entry and its frontend-neutral configuration controls.
268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
269pub struct MiddlewareFeature {
270    pub id: String,
271    pub label: String,
272    pub description: String,
273    pub required: bool,
274    pub settings: Vec<FrontendSetting>,
275}
276
277/// One schema-advertised setting rendered by a thin frontend.
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279pub struct FrontendSetting {
280    pub id: String,
281    pub label: String,
282    pub description: String,
283    #[serde(flatten)]
284    pub kind: FrontendSettingKind,
285}
286
287/// Generic control metadata for a schema-advertised setting.
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
290pub enum FrontendSettingKind {
291    Integer {
292        min: i64,
293        #[serde(default, skip_serializing_if = "Option::is_none")]
294        max: Option<i64>,
295        step: i64,
296    },
297    Select {
298        options: Vec<FrontendSettingOption>,
299        #[serde(default, skip_serializing_if = "Option::is_none")]
300        unset_label: Option<String>,
301    },
302}
303
304/// One exact value in a schema-advertised select control.
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306pub struct FrontendSettingOption {
307    pub value: String,
308    pub label: String,
309    pub description: String,
310}
311
312/// Scalar value accepted by the generic setting controls.
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314#[serde(untagged)]
315pub enum FrontendSettingValue {
316    Integer(i64),
317    String(String),
318}
319
320/// How normal composer input is submitted while a turn is active.
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
322pub struct FrontendActiveInput {
323    pub operation: String,
324}
325
326/// One chat reference supplied by a capability.
327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
328pub struct FrontendReference {
329    pub trigger: char,
330    pub value: String,
331    pub description: String,
332}
333
334/// One capability-rendered view mounted into a standard frontend slot.
335#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
336pub struct FrontendWidget {
337    pub id: String,
338    pub slot: FrontendSlot,
339    pub text: String,
340    pub tone: FrontendTone,
341    pub symbol: Option<FrontendSymbol>,
342    pub icon_only: bool,
343    pub progress: Option<FrontendProgress>,
344    pub content: Option<FrontendWidgetContent>,
345    /// Optional operation invoked when a frontend activates this widget.
346    pub action: Option<Op>,
347}
348
349/// Determinate progress rendered by a frontend widget.
350#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
351pub struct FrontendProgress {
352    pub completed: usize,
353    pub total: usize,
354}
355
356/// Capability-owned content shown when a frontend widget is opened.
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
358#[serde(tag = "type", rename_all = "snake_case")]
359pub enum FrontendWidgetContent {
360    Blocks {
361        title: String,
362        blocks: Vec<FrontendBlock>,
363    },
364    Picker {
365        title: String,
366        options: Vec<FrontendPickerOption>,
367    },
368    ActionList {
369        title: String,
370        items: Vec<FrontendActionListItem>,
371    },
372}
373
374/// Stable locations a thin frontend shell makes available to capabilities.
375#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
376#[serde(rename_all = "snake_case")]
377pub enum FrontendSlot {
378    Header,
379    ComposerHeader,
380    ComposerFooter,
381    MessageActions,
382    /// A transient capability-owned item after the live transcript.
383    TranscriptTail,
384    /// A capability destination mounted by the frontend shell.
385    Navigation,
386    /// A capability action mounted in the current chat's menu.
387    ChatMenu,
388}
389
390/// Capability-rendered transcript content with frontend-neutral formatting and tone.
391#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
392pub struct FrontendBlock {
393    pub id: Option<String>,
394    pub group: Option<String>,
395    pub update: FrontendBlockUpdate,
396    pub state: FrontendBlockState,
397    pub role: FrontendBlockRole,
398    /// Compact, standalone row label. Frontends must not derive this from `text`.
399    pub title: String,
400    /// Expandable body or artifact content.
401    pub text: String,
402    pub symbol: Option<FrontendSymbol>,
403    /// Downloadable files owned by the session rendering this block.
404    pub files: Vec<SessionFileReference>,
405    pub format: FrontendBlockFormat,
406    pub tone: FrontendTone,
407}
408
409/// A block together with its explicit semantic owner.
410#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
411pub struct RenderedBlock {
412    pub capability: String,
413    pub block: FrontendBlock,
414}
415
416/// How a block changes the matching capability-scoped ID.
417#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
418#[serde(rename_all = "snake_case")]
419pub enum FrontendBlockUpdate {
420    Replace,
421    Append,
422}
423
424/// Lifecycle state of one rendered transcript block.
425#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
426#[serde(rename_all = "snake_case")]
427pub enum FrontendBlockState {
428    Pending,
429    Complete,
430}
431
432/// Semantic category used for grouping, summaries, filtering, and icons.
433#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
434#[serde(rename_all = "snake_case")]
435pub enum FrontendBlockRole {
436    Activity,
437    Tool,
438    WebSearch,
439    Artifact,
440    Approval,
441    Notice,
442}
443
444/// Frontend-neutral structure carried by a transcript block.
445#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
446#[serde(rename_all = "snake_case")]
447pub enum FrontendBlockFormat {
448    PlainText,
449    UnifiedDiff,
450}
451
452/// One selectable action supplied by a capability.
453#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
454pub struct FrontendPickerOption {
455    pub label: String,
456    pub description: String,
457    pub detail: String,
458    pub symbol: Option<FrontendSymbol>,
459    pub shows_detail: bool,
460    pub op: Op,
461}
462
463/// One compact status row with optional trailing actions.
464#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
465pub struct FrontendActionListItem {
466    pub id: String,
467    pub text: String,
468    pub state: FrontendListItemState,
469    pub actions: Vec<FrontendAction>,
470}
471
472/// Semantic state for one compact list row.
473#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
474#[serde(rename_all = "snake_case")]
475pub enum FrontendListItemState {
476    Plain,
477    Pending,
478    InProgress,
479    Completed,
480}
481
482/// One labeled, icon-forward action attached to a list item.
483#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
484pub struct FrontendAction {
485    pub id: String,
486    pub label: String,
487    pub symbol: FrontendSymbol,
488    pub tone: FrontendTone,
489    pub op: Op,
490}
491
492/// Generic capability UI updates understood by every frontend.
493#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
494#[serde(tag = "frontend_type", rename_all = "snake_case")]
495pub enum FrontendEvent {
496    Render {
497        capability: String,
498        block: FrontendBlock,
499    },
500    Widget {
501        capability: String,
502        item: FrontendWidget,
503    },
504    RemoveWidget {
505        capability: String,
506        id: String,
507    },
508    Picker {
509        title: String,
510        options: Vec<FrontendPickerOption>,
511    },
512    Preview {
513        id: String,
514        title: String,
515        subtitle: String,
516        page_id: String,
517        update: FrontendPreviewUpdate,
518        events: Vec<EventMsg>,
519        next: Option<Op>,
520    },
521}
522
523/// How one preview page changes the matching frontend preview.
524#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
525#[serde(rename_all = "snake_case")]
526pub enum FrontendPreviewUpdate {
527    Replace,
528    Prepend,
529}
530
531/// A presentation hint rather than a terminal-specific color.
532#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
533#[serde(rename_all = "snake_case")]
534pub enum FrontendTone {
535    Neutral,
536    Success,
537    Warning,
538    Error,
539}
540
541impl EventMsg {
542    /// Renders framework-owned semantic events without frontend prose parsing.
543    #[must_use]
544    pub fn presentation(&self) -> Option<RenderedBlock> {
545        let block = match self {
546            Self::Error(error) => FrontendBlock {
547                id: None,
548                group: None,
549                update: FrontendBlockUpdate::Replace,
550                state: FrontendBlockState::Complete,
551                role: FrontendBlockRole::Notice,
552                title: "Error".into(),
553                text: error.message.clone(),
554                symbol: None,
555                files: Vec::new(),
556                format: FrontendBlockFormat::PlainText,
557                tone: FrontendTone::Error,
558            },
559            Self::Warning(warning) => FrontendBlock {
560                id: None,
561                group: None,
562                update: FrontendBlockUpdate::Replace,
563                state: FrontendBlockState::Complete,
564                role: FrontendBlockRole::Notice,
565                title: "Warning".into(),
566                text: warning.message.clone(),
567                symbol: None,
568                files: Vec::new(),
569                format: FrontendBlockFormat::PlainText,
570                tone: FrontendTone::Warning,
571            },
572            Self::TurnAborted(turn) => FrontendBlock {
573                id: None,
574                group: Some(turn.turn_id.clone()),
575                update: FrontendBlockUpdate::Replace,
576                state: FrontendBlockState::Complete,
577                role: FrontendBlockRole::Notice,
578                title: "Turn aborted".into(),
579                text: turn.reason.clone(),
580                symbol: None,
581                files: Vec::new(),
582                format: FrontendBlockFormat::PlainText,
583                tone: FrontendTone::Warning,
584            },
585            Self::ModelStepCompleted(step) if step.outcome == ModelStepOutcome::Retrying => {
586                FrontendBlock {
587                    id: Some(format!("{}/retry", step.model_step_id)),
588                    group: Some(step.turn_id.clone()),
589                    update: FrontendBlockUpdate::Replace,
590                    state: FrontendBlockState::Complete,
591                    role: FrontendBlockRole::Notice,
592                    title: "Reconnecting…".into(),
593                    text: String::new(),
594                    symbol: None,
595                    files: Vec::new(),
596                    format: FrontendBlockFormat::PlainText,
597                    tone: FrontendTone::Warning,
598                }
599            }
600            Self::WebSearchBegin(search) => FrontendBlock {
601                id: Some(format!("{}/{}", search.model_step_id, search.call_id)),
602                group: Some(search.turn_id.clone()),
603                update: FrontendBlockUpdate::Replace,
604                state: FrontendBlockState::Pending,
605                role: FrontendBlockRole::WebSearch,
606                title: "Searching the web".into(),
607                text: String::new(),
608                symbol: Some(FrontendSymbol::Search),
609                files: Vec::new(),
610                format: FrontendBlockFormat::PlainText,
611                tone: FrontendTone::Neutral,
612            },
613            Self::WebSearchEnd(search) => {
614                let (title, text, tone) = match &search.action {
615                    WebSearchAction::Search { queries } => (
616                        "Searched the web",
617                        queries.join("\n"),
618                        FrontendTone::Success,
619                    ),
620                    WebSearchAction::OpenPage { url } => (
621                        "Opened a web page",
622                        url.clone().unwrap_or_default(),
623                        FrontendTone::Success,
624                    ),
625                    WebSearchAction::FindInPage { url, pattern } => {
626                        let text = match (url, pattern) {
627                            (Some(url), Some(pattern)) => format!("{pattern}\n{url}"),
628                            (Some(url), None) => url.clone(),
629                            (None, Some(pattern)) => pattern.clone(),
630                            (None, None) => String::new(),
631                        };
632                        ("Searched a web page", text, FrontendTone::Success)
633                    }
634                    WebSearchAction::Interrupted => (
635                        "Web search interrupted",
636                        String::new(),
637                        FrontendTone::Warning,
638                    ),
639                    WebSearchAction::Other => {
640                        ("Web search complete", String::new(), FrontendTone::Success)
641                    }
642                };
643                FrontendBlock {
644                    id: Some(format!("{}/{}", search.model_step_id, search.call_id)),
645                    group: Some(search.turn_id.clone()),
646                    update: FrontendBlockUpdate::Replace,
647                    state: FrontendBlockState::Complete,
648                    role: FrontendBlockRole::WebSearch,
649                    title: title.into(),
650                    text,
651                    symbol: Some(FrontendSymbol::Search),
652                    files: Vec::new(),
653                    format: FrontendBlockFormat::PlainText,
654                    tone,
655                }
656            }
657            Self::Frontend(FrontendEvent::Render { capability, block }) => {
658                return Some(RenderedBlock {
659                    capability: capability.clone(),
660                    block: block.clone(),
661                });
662            }
663            _ => return None,
664        };
665        Some(RenderedBlock {
666            capability: match self {
667                Self::WebSearchBegin(_) | Self::WebSearchEnd(_) => "web_search",
668                _ => "agent",
669            }
670            .into(),
671            block,
672        })
673    }
674}
675
676/// A presentation hint rather than a name from any one icon set, the same way
677/// [`FrontendTone`] names a role instead of a color.
678///
679/// A gateway does not know whether the frontend draws SF Symbols, terminal glyphs, or
680/// SVGs, so it names what a glyph stands for and each frontend supplies its own artwork.
681/// Most variants are roles. The rest are provider identity, where no role applies: some
682/// name the vendor outright (`ChatGpt`, `Claude`, `Deepseek`, `Kimi`) and the others name
683/// what the mark depicts (`Moon`, `Sparkle`) where a frontend has no vendor artwork to draw.
684///
685/// [`Self::Custom`] carries anything outside this list so a plugin can still ship a glyph
686/// this enum has never heard of. It is explicitly best-effort: a frontend that cannot
687/// resolve the name falls back to a placeholder, which is why everything shipped in-tree
688/// should earn a variant instead.
689#[derive(Debug, Clone, PartialEq, Eq)]
690pub enum FrontendSymbol {
691    Agent,
692    Brain,
693    Branch,
694    Chat,
695    ChatGpt,
696    Claude,
697    Deepseek,
698    Delete,
699    Edit,
700    Kimi,
701    Moon,
702    Promote,
703    Route,
704    Search,
705    Sparkle,
706    Storage,
707    Task,
708    Custom(String),
709}
710
711impl FrontendSymbol {
712    /// The wire name. Also the stable token capabilities build action ids from.
713    pub fn as_str(&self) -> &str {
714        match self {
715            Self::Agent => "agent",
716            Self::Brain => "brain",
717            Self::Branch => "branch",
718            Self::Chat => "chat",
719            Self::ChatGpt => "chat_gpt",
720            Self::Claude => "claude",
721            Self::Deepseek => "deepseek",
722            Self::Delete => "delete",
723            Self::Edit => "edit",
724            Self::Kimi => "kimi",
725            Self::Moon => "moon",
726            Self::Promote => "promote",
727            Self::Route => "route",
728            Self::Search => "search",
729            Self::Sparkle => "sparkle",
730            Self::Storage => "storage",
731            Self::Task => "task",
732            Self::Custom(name) => name,
733        }
734    }
735
736    /// Unknown names become [`Self::Custom`] rather than an error: a frontend rendering a
737    /// placeholder is a better outcome than a gateway refusing to decode a whole frame.
738    fn from_wire(name: &str) -> Self {
739        match name {
740            "agent" => Self::Agent,
741            "brain" => Self::Brain,
742            "branch" => Self::Branch,
743            "chat" => Self::Chat,
744            "chat_gpt" => Self::ChatGpt,
745            "claude" => Self::Claude,
746            "deepseek" => Self::Deepseek,
747            "delete" => Self::Delete,
748            "edit" => Self::Edit,
749            "kimi" => Self::Kimi,
750            "moon" => Self::Moon,
751            "promote" => Self::Promote,
752            "route" => Self::Route,
753            "search" => Self::Search,
754            "sparkle" => Self::Sparkle,
755            "storage" => Self::Storage,
756            "task" => Self::Task,
757            other => Self::Custom(other.to_owned()),
758        }
759    }
760}
761
762impl std::fmt::Display for FrontendSymbol {
763    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
764        formatter.write_str(self.as_str())
765    }
766}
767
768impl Serialize for FrontendSymbol {
769    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
770        serializer.serialize_str(self.as_str())
771    }
772}
773
774impl<'de> Deserialize<'de> for FrontendSymbol {
775    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
776        // A known name round-trips out of `Custom` on the way back in, so the two spellings
777        // of the same glyph cannot drift apart once a frame has crossed the wire.
778        String::deserialize(deserializer).map(|name| Self::from_wire(&name))
779    }
780}
781
782#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
783pub struct ErrorEvent {
784    pub kind: ErrorKind,
785    pub message: String,
786    pub retryable: bool,
787    pub status: Option<u16>,
788    pub retry_after: Option<String>,
789}
790
791impl ErrorEvent {
792    pub(crate) fn from_error(error: &crate::Error) -> Self {
793        let (kind, retryable, status, retry_after) = match error {
794            crate::Error::Config(_) => (ErrorKind::Configuration, false, None, None),
795            crate::Error::Duplicate(_) => (ErrorKind::DuplicateRegistration, false, None, None),
796            crate::Error::Unknown(_) => (ErrorKind::UnknownRegistration, false, None, None),
797            crate::Error::Provider(error) => (
798                ErrorKind::Provider,
799                error.is_retryable(),
800                error.status(),
801                error.retry_after().map(str::to_owned),
802            ),
803            crate::Error::Auth(_) => (ErrorKind::Authentication, false, None, None),
804            crate::Error::Sandbox(_) => (ErrorKind::Sandbox, false, None, None),
805            crate::Error::Tool(_) => (ErrorKind::Tool, false, None, None),
806            crate::Error::Checkpoint(_) => (ErrorKind::Checkpoint, false, None, None),
807            crate::Error::Busy(_) => (ErrorKind::Busy, false, None, None),
808            crate::Error::Stopped(_) => (ErrorKind::Stopped, false, None, None),
809            crate::Error::Rollback { .. } => (ErrorKind::Rollback, false, None, None),
810            crate::Error::Io(_) => (ErrorKind::Io, false, None, None),
811            crate::Error::Http(_) => (ErrorKind::Http, false, None, None),
812            crate::Error::Json(_) => (ErrorKind::Json, false, None, None),
813            crate::Error::Sqlite(_) => (ErrorKind::Storage, false, None, None),
814        };
815        Self {
816            kind,
817            message: error.to_string(),
818            retryable,
819            status,
820            retry_after,
821        }
822    }
823}
824
825/// Stable frontend classification for framework failures.
826#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
827#[serde(rename_all = "snake_case")]
828pub enum ErrorKind {
829    Configuration,
830    DuplicateRegistration,
831    UnknownRegistration,
832    Provider,
833    Authentication,
834    Sandbox,
835    Tool,
836    Checkpoint,
837    Busy,
838    Stopped,
839    Rollback,
840    Io,
841    Http,
842    Json,
843    Storage,
844}
845
846#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
847pub struct WarningEvent {
848    pub message: String,
849}
850
851/// Immutable session data emitted once when an agent starts or resumes.
852#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
853pub struct SessionConfiguredEvent {
854    pub session_id: String,
855    pub context: SessionContext,
856    pub model: ModelChangedEvent,
857}
858
859#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
860pub struct TurnStartedEvent {
861    pub turn_id: String,
862    pub model_context_window: Option<i64>,
863}
864
865#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
866pub struct TurnCompleteEvent {
867    pub turn_id: String,
868}
869
870#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
871pub struct TurnAbortedEvent {
872    pub turn_id: String,
873    pub reason: String,
874}
875
876/// Exact durable transcript prefix selected by a message action.
877#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
878pub struct MessageTarget {
879    /// Durable checkpoint sequence containing the selected message.
880    pub checkpoint_sequence: u64,
881    /// One-based item count within the checkpoint's transcript batch.
882    #[serde(deserialize_with = "positive_usize")]
883    pub batch_item_count: usize,
884}
885
886fn positive_usize<'de, D>(deserializer: D) -> std::result::Result<usize, D::Error>
887where
888    D: Deserializer<'de>,
889{
890    let value = usize::deserialize(deserializer)?;
891    if value == 0 {
892        return Err(serde::de::Error::custom(
893            "message target item count must be positive",
894        ));
895    }
896    Ok(value)
897}
898
899#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
900pub struct UserMessageEvent {
901    pub message: String,
902    pub attachments: Vec<SessionFileReference>,
903    #[serde(deserialize_with = "required_option")]
904    pub message_target: Option<MessageTarget>,
905}
906
907#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
908pub struct AgentMessageEvent {
909    pub session_id: String,
910    pub turn_id: String,
911    pub model_step_id: String,
912    pub message: String,
913    pub phase: AgentMessagePhase,
914    #[serde(deserialize_with = "required_option")]
915    pub message_target: Option<MessageTarget>,
916}
917
918#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
919pub struct AgentMessageContentDeltaEvent {
920    pub session_id: String,
921    pub turn_id: String,
922    pub model_step_id: String,
923    pub delta: String,
924    pub phase: AgentMessagePhase,
925}
926
927#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
928pub struct AgentReasoningContentDeltaEvent {
929    pub session_id: String,
930    pub turn_id: String,
931    pub model_step_id: String,
932    pub delta: String,
933}
934
935/// One provider request becoming active within a turn.
936#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
937pub struct ModelStepStartedEvent {
938    pub session_id: String,
939    pub turn_id: String,
940    pub model_step_id: String,
941    pub step_index: usize,
942    pub started_at_ms: i64,
943}
944
945/// The terminal record for one provider request.
946#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
947pub struct ModelStepCompletedEvent {
948    pub session_id: String,
949    pub turn_id: String,
950    pub model_step_id: String,
951    pub step_index: usize,
952    pub started_at_ms: i64,
953    pub completed_at_ms: i64,
954    pub outcome: ModelStepOutcome,
955}
956
957/// Provider-neutral outcome of a completed model step.
958#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
959#[serde(tag = "status", rename_all = "snake_case")]
960pub enum ModelStepOutcome {
961    Completed {
962        end_turn: bool,
963        tool_call_ids: Vec<String>,
964        usage: TokenUsage,
965        content: Vec<ModelStepContent>,
966    },
967    Failed,
968    Interrupted,
969    /// The response stream failed and the logical step will restart with a fresh ID.
970    Retrying,
971}
972
973/// One complete normalized text item produced by a model step.
974#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
975pub struct ModelStepContent {
976    pub output_index: usize,
977    pub part_index: usize,
978    pub phase: ModelStepContentPhase,
979    pub text: String,
980    pub annotations: Vec<ModelStepAnnotation>,
981}
982
983/// A provider-neutral annotation attached to one complete text part.
984#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
985#[serde(tag = "type", rename_all = "snake_case")]
986pub enum ModelStepAnnotation {
987    UrlCitation {
988        url: String,
989        title: String,
990        start_index: usize,
991        end_index: usize,
992    },
993    FileCitation {
994        file_id: String,
995        filename: String,
996        index: usize,
997    },
998    ContainerFileCitation {
999        container_id: String,
1000        file_id: String,
1001        filename: String,
1002        start_index: usize,
1003        end_index: usize,
1004    },
1005    FilePath {
1006        file_id: String,
1007        index: usize,
1008    },
1009    DocumentCharacterCitation {
1010        cited_text: String,
1011        document_index: usize,
1012        document_title: Option<String>,
1013        file_id: Option<String>,
1014        start_char_index: usize,
1015        end_char_index: usize,
1016    },
1017    DocumentPageCitation {
1018        cited_text: String,
1019        document_index: usize,
1020        document_title: Option<String>,
1021        file_id: Option<String>,
1022        start_page_number: usize,
1023        end_page_number: usize,
1024    },
1025    DocumentContentBlockCitation {
1026        cited_text: String,
1027        document_index: usize,
1028        document_title: Option<String>,
1029        file_id: Option<String>,
1030        start_block_index: usize,
1031        end_block_index: usize,
1032    },
1033    SearchResultCitation {
1034        cited_text: String,
1035        search_result_index: usize,
1036        source: String,
1037        title: Option<String>,
1038        start_block_index: usize,
1039        end_block_index: usize,
1040    },
1041    WebSearchResultCitation {
1042        cited_text: String,
1043        encrypted_index: String,
1044        title: Option<String>,
1045        url: String,
1046    },
1047}
1048
1049/// Semantic role of text preserved in a completed model step.
1050#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1051#[serde(rename_all = "snake_case")]
1052pub enum ModelStepContentPhase {
1053    Reasoning,
1054    Commentary,
1055    FinalAnswer,
1056}
1057
1058/// A restored transcript kept distinct from live turn lifecycle events.
1059#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1060pub struct SessionHistoryEvent {
1061    pub events: Vec<EventMsg>,
1062}
1063
1064#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1065pub struct ModelChangedEvent {
1066    pub route: String,
1067    pub model: String,
1068    pub reasoning_effort: Option<String>,
1069    pub model_context_window: Option<i64>,
1070}
1071
1072#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1073pub struct SessionResumeRequestedEvent {
1074    pub session_id: String,
1075    pub context: SessionContext,
1076}
1077
1078/// Assistant message phases understood by frontends.
1079#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1080#[serde(rename_all = "snake_case")]
1081pub enum AgentMessagePhase {
1082    Commentary,
1083    FinalAnswer,
1084}
1085
1086#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1087pub struct ToolCallBeginEvent {
1088    pub turn_id: String,
1089    pub call_id: String,
1090    pub name: String,
1091    pub arguments: serde_json::Value,
1092}
1093
1094#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1095pub struct ToolCallEndEvent {
1096    pub turn_id: String,
1097    pub call_id: String,
1098    pub name: String,
1099    pub output: String,
1100    pub is_error: bool,
1101}
1102
1103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1104pub struct ExecApprovalRequestEvent {
1105    pub id: String,
1106    pub turn_id: String,
1107    pub calls: Vec<ApprovalCall>,
1108    pub reason: String,
1109}
1110
1111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1112pub struct ExecApprovalReviewEvent {
1113    pub id: String,
1114    pub turn_id: String,
1115    pub calls: Vec<ApprovalCall>,
1116    pub status: ApprovalReviewStatus,
1117    #[serde(skip_serializing_if = "Option::is_none")]
1118    pub reason: Option<ApprovalReviewEscalation>,
1119}
1120
1121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1122#[serde(rename_all = "snake_case")]
1123pub enum ApprovalReviewStatus {
1124    Reviewing,
1125    Approved,
1126    Escalated,
1127}
1128
1129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1130#[serde(rename_all = "snake_case")]
1131pub enum ApprovalReviewEscalation {
1132    ReviewerAsked,
1133    ReviewDataUnavailable,
1134    ReviewerUnavailable,
1135    InvalidResponse,
1136}
1137
1138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1139pub struct ApprovalCall {
1140    pub call_id: String,
1141    pub name: String,
1142    pub arguments: serde_json::Value,
1143}
1144
1145/// A user's decision for a paused tool batch.
1146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1147#[serde(rename_all = "snake_case")]
1148pub enum ReviewDecision {
1149    Approved,
1150    ApprovedForSession,
1151    Denied { rejection: String },
1152    Abort,
1153}
1154
1155#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1156pub struct TokenUsage {
1157    pub input_tokens: i64,
1158    pub cached_input_tokens: i64,
1159    pub cache_write_input_tokens: i64,
1160    pub output_tokens: i64,
1161    pub reasoning_output_tokens: i64,
1162    pub total_tokens: i64,
1163}
1164
1165impl TokenUsage {
1166    /// Adds another response's usage, returning `None` on integer overflow.
1167    pub fn checked_add(&mut self, other: &Self) -> Option<()> {
1168        let input_tokens = self.input_tokens.checked_add(other.input_tokens)?;
1169        let cached_input_tokens = self
1170            .cached_input_tokens
1171            .checked_add(other.cached_input_tokens)?;
1172        let cache_write_input_tokens = self
1173            .cache_write_input_tokens
1174            .checked_add(other.cache_write_input_tokens)?;
1175        let output_tokens = self.output_tokens.checked_add(other.output_tokens)?;
1176        let reasoning_output_tokens = self
1177            .reasoning_output_tokens
1178            .checked_add(other.reasoning_output_tokens)?;
1179        let total_tokens = self.total_tokens.checked_add(other.total_tokens)?;
1180        *self = Self {
1181            input_tokens,
1182            cached_input_tokens,
1183            cache_write_input_tokens,
1184            output_tokens,
1185            reasoning_output_tokens,
1186            total_tokens,
1187        };
1188        Some(())
1189    }
1190}
1191
1192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1193pub struct TokenUsageInfo {
1194    pub total_token_usage: TokenUsage,
1195    pub last_token_usage: TokenUsage,
1196    pub model_context_window: Option<i64>,
1197}
1198
1199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1200pub struct TokenCountEvent {
1201    pub info: Option<TokenUsageInfo>,
1202    pub rate_limits: Option<serde_json::Value>,
1203}
1204
1205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1206pub struct WebSearchBeginEvent {
1207    pub session_id: String,
1208    pub turn_id: String,
1209    pub model_step_id: String,
1210    pub call_id: String,
1211}
1212
1213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1214pub struct WebSearchEndEvent {
1215    pub session_id: String,
1216    pub turn_id: String,
1217    pub model_step_id: String,
1218    pub call_id: String,
1219    pub action: WebSearchAction,
1220}
1221
1222#[cfg(test)]
1223mod tests {
1224    use serde_json::json;
1225
1226    use super::*;
1227
1228    #[test]
1229    fn model_events_keep_typed_correlation_and_web_search_fields() {
1230        let delta = ModelEvent::CommentaryDelta("Checking".into()).into_event(
1231            "session-1",
1232            "turn-1",
1233            "step-1",
1234        );
1235        let search = ModelEvent::WebSearchCompleted {
1236            call_id: "search-1".into(),
1237            action: WebSearchAction::Search {
1238                queries: vec!["Horus framework".into(), "Horus gateway".into()],
1239            },
1240        }
1241        .into_event("session-1", "turn-1", "step-1");
1242
1243        assert_eq!(
1244            serde_json::to_value(delta).expect("serialize delta"),
1245            json!({
1246                "type": "agent_message_content_delta",
1247                "session_id": "session-1",
1248                "turn_id": "turn-1",
1249                "model_step_id": "step-1",
1250                "delta": "Checking",
1251                "phase": "commentary"
1252            })
1253        );
1254        assert_eq!(
1255            serde_json::to_value(search).expect("serialize web search"),
1256            json!({
1257                "type": "web_search_end",
1258                "session_id": "session-1",
1259                "turn_id": "turn-1",
1260                "model_step_id": "step-1",
1261                "call_id": "search-1",
1262                "action": {
1263                    "type": "search",
1264                    "queries": ["Horus framework", "Horus gateway"]
1265                }
1266            })
1267        );
1268    }
1269
1270    #[test]
1271    fn interrupted_web_search_renders_a_terminal_warning_block() {
1272        let event = EventMsg::WebSearchEnd(WebSearchEndEvent {
1273            session_id: "session-1".into(),
1274            turn_id: "turn-1".into(),
1275            model_step_id: "step-1".into(),
1276            call_id: "search-1".into(),
1277            action: WebSearchAction::Interrupted,
1278        });
1279
1280        assert_eq!(
1281            serde_json::to_value(&event).expect("serialize interrupted search"),
1282            json!({
1283                "type": "web_search_end",
1284                "session_id": "session-1",
1285                "turn_id": "turn-1",
1286                "model_step_id": "step-1",
1287                "call_id": "search-1",
1288                "action": {"type": "interrupted"}
1289            })
1290        );
1291        let block = event.presentation().expect("interrupted search renders");
1292        assert_eq!(block.capability, "web_search");
1293        assert_eq!(block.block.id.as_deref(), Some("step-1/search-1"));
1294        assert_eq!(block.block.state, FrontendBlockState::Complete);
1295        assert_eq!(block.block.tone, FrontendTone::Warning);
1296        assert_eq!(&*block.block.title, "Web search interrupted");
1297    }
1298
1299    #[test]
1300    fn retrying_model_step_has_a_provider_neutral_reconnect_notice() {
1301        let event = EventMsg::ModelStepCompleted(ModelStepCompletedEvent {
1302            session_id: "session-1".into(),
1303            turn_id: "turn-1".into(),
1304            model_step_id: "step-1".into(),
1305            step_index: 0,
1306            started_at_ms: 1,
1307            completed_at_ms: 2,
1308            outcome: ModelStepOutcome::Retrying,
1309        });
1310
1311        let rendered = event.presentation().expect("retry presentation");
1312
1313        assert_eq!(rendered.capability, "agent");
1314        assert_eq!(rendered.block.id.as_deref(), Some("step-1/retry"));
1315        assert_eq!(rendered.block.title, "Reconnecting…");
1316        assert_eq!(rendered.block.tone, FrontendTone::Warning);
1317        assert_eq!(rendered.block.state, FrontendBlockState::Complete);
1318    }
1319
1320    #[test]
1321    fn middleware_settings_have_a_generic_wire_shape() {
1322        let feature = MiddlewareFeature {
1323            id: "example".into(),
1324            label: "Example".into(),
1325            description: "Example capability".into(),
1326            required: false,
1327            settings: vec![FrontendSetting {
1328                id: "limit".into(),
1329                label: "Limit".into(),
1330                description: "Example limit".into(),
1331                kind: FrontendSettingKind::Integer {
1332                    min: 1,
1333                    max: None,
1334                    step: 10,
1335                },
1336            }],
1337        };
1338
1339        assert_eq!(
1340            serde_json::to_value(feature).expect("serialize middleware setting"),
1341            json!({
1342                "id": "example",
1343                "label": "Example",
1344                "description": "Example capability",
1345                "required": false,
1346                "settings": [{
1347                    "id": "limit",
1348                    "label": "Limit",
1349                    "description": "Example limit",
1350                    "type": "integer",
1351                    "min": 1,
1352                    "step": 10
1353                }]
1354            })
1355        );
1356    }
1357
1358    #[test]
1359    fn session_configured_has_a_stable_wire_shape() {
1360        let event = EventMsg::SessionConfigured(SessionConfiguredEvent {
1361            session_id: "session-1".into(),
1362            context: SessionContext {
1363                tenant_id: Some("tenant-1".into()),
1364                user_id: Some("user-1".into()),
1365                user_name: Some("Ada".into()),
1366                workspace_id: Some("workspace-1".into()),
1367                workspace_label: Some("Project One".into()),
1368                origin_label: Some("cron".into()),
1369            },
1370            model: ModelChangedEvent {
1371                route: "default".into(),
1372                model: "test-model".into(),
1373                reasoning_effort: Some("high".into()),
1374                model_context_window: Some(128_000),
1375            },
1376        });
1377
1378        assert_eq!(
1379            serde_json::to_value(event).expect("serialize session event"),
1380            json!({
1381                "type": "session_configured",
1382                "session_id": "session-1",
1383                "context": {
1384                    "tenant_id": "tenant-1",
1385                    "user_id": "user-1",
1386                    "user_name": "Ada",
1387                    "workspace_id": "workspace-1",
1388                    "workspace_label": "Project One",
1389                    "origin_label": "cron"
1390                },
1391                "model": {
1392                    "route": "default",
1393                    "model": "test-model",
1394                    "reasoning_effort": "high",
1395                    "model_context_window": 128_000
1396                }
1397            })
1398        );
1399    }
1400
1401    #[test]
1402    fn session_resume_request_carries_the_target_context() {
1403        let event = EventMsg::SessionResumeRequested(SessionResumeRequestedEvent {
1404            session_id: "session-2".into(),
1405            context: SessionContext {
1406                workspace_label: Some("Project Two".into()),
1407                origin_label: Some("cron".into()),
1408                ..SessionContext::default()
1409            },
1410        });
1411
1412        assert_eq!(
1413            serde_json::to_value(event).expect("serialize resume event"),
1414            json!({
1415                "type": "session_resume_requested",
1416                "session_id": "session-2",
1417                "context": {
1418                    "workspace_label": "Project Two",
1419                    "origin_label": "cron"
1420                }
1421            })
1422        );
1423    }
1424
1425    #[test]
1426    fn frontend_event_has_a_distinct_nested_discriminator() {
1427        let event = EventMsg::Frontend(FrontendEvent::Widget {
1428            capability: "subagents".into(),
1429            item: FrontendWidget {
1430                id: "status".into(),
1431                slot: FrontendSlot::ComposerHeader,
1432                text: "2 agents".into(),
1433                tone: FrontendTone::Neutral,
1434                symbol: Some(FrontendSymbol::Agent),
1435                icon_only: true,
1436                progress: None,
1437                content: None,
1438                action: None,
1439            },
1440        });
1441        let value = serde_json::to_value(&event).expect("serialize frontend event");
1442
1443        assert_eq!(value["type"], "frontend");
1444        assert_eq!(value["frontend_type"], "widget");
1445        assert_eq!(
1446            serde_json::from_value::<EventMsg>(value).expect("deserialize frontend event"),
1447            event
1448        );
1449    }
1450
1451    #[test]
1452    fn capability_surface_slots_have_stable_wire_names() {
1453        assert_eq!(
1454            serde_json::to_value(FrontendSlot::Navigation).expect("navigation slot"),
1455            json!("navigation")
1456        );
1457        assert_eq!(
1458            serde_json::to_value(FrontendSlot::ChatMenu).expect("chat menu slot"),
1459            json!("chat_menu")
1460        );
1461        assert_eq!(
1462            serde_json::to_value(FrontendSlot::TranscriptTail).expect("transcript tail slot"),
1463            json!("transcript_tail")
1464        );
1465    }
1466
1467    #[test]
1468    fn interrupt_has_a_targeted_wire_shape() {
1469        let submission = Submission {
1470            id: "cancel-1".into(),
1471            op: Op::Interrupt {
1472                turn_id: "turn-1".into(),
1473            },
1474        };
1475
1476        assert_eq!(
1477            serde_json::to_value(submission).expect("serialize interrupt"),
1478            json!({
1479                "id": "cancel-1",
1480                "op": {
1481                    "type": "interrupt",
1482                    "turn_id": "turn-1"
1483                }
1484            })
1485        );
1486    }
1487
1488    #[test]
1489    fn user_input_has_one_text_payload() {
1490        let submission = Submission {
1491            id: "input-1".into(),
1492            op: Op::UserInput {
1493                text: "hello".into(),
1494                attachments: Vec::new(),
1495            },
1496        };
1497
1498        assert_eq!(
1499            serde_json::to_value(submission).expect("serialize input"),
1500            json!({
1501                "id": "input-1",
1502                "op": {
1503                    "type": "user_input",
1504                    "text": "hello",
1505                    "attachments": []
1506                }
1507            })
1508        );
1509    }
1510
1511    #[test]
1512    fn system_event_omits_submission_correlation() {
1513        let event = Event {
1514            submission_id: None,
1515            msg: EventMsg::Warning(WarningEvent {
1516                message: "system notice".into(),
1517            }),
1518        };
1519
1520        assert_eq!(
1521            serde_json::to_value(event).expect("serialize system event"),
1522            json!({
1523                "msg": {
1524                    "type": "warning",
1525                    "message": "system notice"
1526                }
1527            })
1528        );
1529    }
1530
1531    #[test]
1532    fn context_compacted_is_a_unit_event() {
1533        assert_eq!(
1534            serde_json::to_value(EventMsg::ContextCompacted).expect("serialize compaction"),
1535            json!({"type": "context_compacted"})
1536        );
1537    }
1538
1539    #[test]
1540    fn approval_review_serializes_the_wire_contract() {
1541        let calls = || {
1542            vec![ApprovalCall {
1543                call_id: "call-1".into(),
1544                name: "bash".into(),
1545                arguments: json!({"command": "git status"}),
1546            }]
1547        };
1548        let reviewing = EventMsg::ExecApprovalReview(ExecApprovalReviewEvent {
1549            id: "approval-1".into(),
1550            turn_id: "turn-1".into(),
1551            calls: calls(),
1552            status: ApprovalReviewStatus::Reviewing,
1553            reason: None,
1554        });
1555        let escalated = EventMsg::ExecApprovalReview(ExecApprovalReviewEvent {
1556            id: "approval-1".into(),
1557            turn_id: "turn-1".into(),
1558            calls: calls(),
1559            status: ApprovalReviewStatus::Escalated,
1560            reason: Some(ApprovalReviewEscalation::ReviewerAsked),
1561        });
1562
1563        // Clients reject a null reason for reviewing/approved, so the key stays absent.
1564        assert_eq!(
1565            serde_json::to_value(reviewing).expect("serialize reviewing"),
1566            json!({
1567                "type": "exec_approval_review",
1568                "id": "approval-1",
1569                "turn_id": "turn-1",
1570                "calls": [{"call_id": "call-1", "name": "bash", "arguments": {"command": "git status"}}],
1571                "status": "reviewing"
1572            })
1573        );
1574        assert_eq!(
1575            serde_json::to_value(escalated).expect("serialize escalated"),
1576            json!({
1577                "type": "exec_approval_review",
1578                "id": "approval-1",
1579                "turn_id": "turn-1",
1580                "calls": [{"call_id": "call-1", "name": "bash", "arguments": {"command": "git status"}}],
1581                "status": "escalated",
1582                "reason": "reviewer_asked"
1583            })
1584        );
1585    }
1586
1587    #[test]
1588    fn token_usage_overflow_does_not_partially_update_the_total() {
1589        let mut total = TokenUsage {
1590            input_tokens: 7,
1591            total_tokens: i64::MAX,
1592            ..TokenUsage::default()
1593        };
1594        let original = total.clone();
1595
1596        assert!(
1597            total
1598                .checked_add(&TokenUsage {
1599                    input_tokens: 1,
1600                    total_tokens: 1,
1601                    ..TokenUsage::default()
1602                })
1603                .is_none()
1604        );
1605        assert_eq!(total, original);
1606    }
1607
1608    #[test]
1609    fn symbols_round_trip_and_keep_unknown_names() {
1610        for symbol in [
1611            FrontendSymbol::Agent,
1612            FrontendSymbol::Brain,
1613            FrontendSymbol::Branch,
1614            FrontendSymbol::Chat,
1615            FrontendSymbol::ChatGpt,
1616            FrontendSymbol::Claude,
1617            FrontendSymbol::Deepseek,
1618            FrontendSymbol::Delete,
1619            FrontendSymbol::Edit,
1620            FrontendSymbol::Kimi,
1621            FrontendSymbol::Moon,
1622            FrontendSymbol::Promote,
1623            FrontendSymbol::Route,
1624            FrontendSymbol::Search,
1625            FrontendSymbol::Sparkle,
1626            FrontendSymbol::Storage,
1627            FrontendSymbol::Task,
1628        ] {
1629            let json = serde_json::to_string(&symbol).expect("symbol serializes");
1630            assert_eq!(json, format!("\"{}\"", symbol.as_str()));
1631            let decoded: FrontendSymbol = serde_json::from_str(&json).expect("symbol deserializes");
1632            assert_eq!(decoded, symbol);
1633        }
1634
1635        // A name this build has never heard of survives instead of failing the frame.
1636        let custom: FrontendSymbol =
1637            serde_json::from_str("\"telescope\"").expect("unknown symbol deserializes");
1638        assert_eq!(custom, FrontendSymbol::Custom("telescope".into()));
1639        assert_eq!(custom.as_str(), "telescope");
1640
1641        // A known name never lingers as a `Custom` once it has crossed the wire, so the two
1642        // spellings of one glyph cannot compare unequal.
1643        let normalized: FrontendSymbol = serde_json::from_str(
1644            &serde_json::to_string(&FrontendSymbol::Custom("edit".into()))
1645                .expect("custom serializes"),
1646        )
1647        .expect("custom deserializes");
1648        assert_eq!(normalized, FrontendSymbol::Edit);
1649    }
1650}