Skip to main content

ante_protocol_shape/
msg.rs

1use std::path::PathBuf;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Deserializer, Serialize};
5
6use crate::id::Id;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct EventMsg {
10    pub timestamp: DateTime<Utc>,
11    pub id: Id,
12    pub event: Evt,
13    #[serde(skip_serializing_if = "Option::is_none")]
14    pub parent: Option<Id>,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct OpMsg {
19    pub op: Op,
20    pub id: Id,
21}
22
23#[allow(clippy::large_enum_variant)]
24#[derive(Debug, Clone, Deserialize, Serialize)]
25pub enum Op {
26    StartSession(SessionOverrides),
27    UpdateSession(SessionUpdate),
28    Interrupt,
29    UserInput(String),
30    ShellInput(String),
31    Steer(String),
32    ApprovalResponse {
33        turn_id: Id,
34        responses: Vec<(String, ReviewDecision)>,
35    },
36    SlashCommand {
37        name: String,
38        args: String,
39    },
40    ResumeSession {
41        session_id: Id,
42    },
43    RegisterLocalProvider {
44        port: u16,
45        model: Option<ModelSpec>,
46    },
47    RestoreLocalProvider,
48    /// Manually trigger conversation compaction on the active session.
49    Compact,
50    /// Request a per-category breakdown of the active session's context-window
51    /// occupancy. Answered with [`Evt::ContextReport`].
52    ContextReport,
53    /// Set, clear, or report a goal-driven execution loop on the active
54    /// session. A set goal keeps the session working — re-running turns and
55    /// judging the condition after each one — until it is met, judged
56    /// unreachable, or cleared.
57    Goal(GoalCommand),
58    /// Request an ad-hoc "thinking phrase" prediction for the in-progress
59    /// draft. Runs off the conversation critical path on a cheap model and
60    /// answers with `Evt::Ambient`. `req_id` lets the client discard stale
61    /// results when a newer request supersedes this one.
62    AmbientPhrase {
63        draft: String,
64        req_id: u64,
65    },
66    /// Request an ad-hoc next-prompt suggestion from the last exchange, shown as
67    /// input ghost text. Like [`Op::AmbientPhrase`] it runs off the critical
68    /// path on a cheap model and answers with `Evt::Ambient`. The client carries
69    /// the context (so this stays a client-only feature — headless never fires
70    /// it) and a monotonic `req_id` to drop stale results.
71    AmbientSuggestion {
72        recent_user: String,
73        recent_agent: String,
74        req_id: u64,
75    },
76    Shutdown,
77}
78
79/// A `/goal` sub-command carried by [`Op::Goal`].
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
81pub enum GoalCommand {
82    /// Set (or replace) the active goal condition.
83    Set(String),
84    /// Clear the active goal, stopping the loop.
85    Clear,
86    /// Report the current goal status.
87    Status,
88}
89
90/// Which ambient feature produced an [`Evt::Ambient`]. Both run off the main
91/// conversation on a cheap model; they differ in trigger, prompt, and sink.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
93pub enum AmbientKind {
94    /// Predicted phrase for the in-progress draft, shown in the status spinner.
95    ThinkingPhrase,
96    /// Suggested next prompt from the conversation so far, shown as input ghost text.
97    PromptSuggestion,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub enum Evt {
102    SessionStart(Box<SessionInitialized>),
103    SessionUpdated(Box<SessionInitialized>),
104    ExtensionRefreshed(Box<ExtensionRefreshed>),
105    /// The session span closed. Mirrors `TurnEnd`: carries the span's
106    /// identity, why it ended, and its final usage accounting.
107    SessionEnd {
108        session_id: Id,
109        reason: SessionEndReason,
110        usage: Usage,
111    },
112    UserInput(String),
113    ShellOutput {
114        command: String,
115        stdout: String,
116        stderr: String,
117        exit_code: Option<i32>,
118    },
119    AgentMessage(String),
120    Thinking(String),
121    MessageDelta(String),
122    ThinkingDelta(String),
123    Info(String),
124    /// Open a grouped Info entry with `header`. Subsequent
125    /// `InfoBlockAppend` events with the same `id` are rendered as
126    /// tree-indented child lines under it. Use for multi-step background
127    /// notifications (e.g. MCP warm-up) that should visually cluster.
128    ///
129    /// When `loading` is true the renderer appends an animated `.`/`../...`
130    /// suffix to the header until the first `InfoBlockAppend` arrives,
131    /// signalling that background work is still in flight.
132    InfoBlockStart {
133        id: String,
134        header: String,
135        #[serde(default)]
136        loading: bool,
137    },
138    /// Append a child detail line to the `InfoBlockStart` with the same `id`.
139    /// Drops silently if the matching block isn't present.
140    InfoBlockAppend {
141        id: String,
142        detail: String,
143    },
144    Error(String),
145    ToolStart(ToolUse),
146    ToolUpdate(ToolUpdate),
147    ToolEnd(ToolEnd),
148    CompactStart,
149    /// Compaction finished. `summary` is the text that replaced the
150    /// compacted history and carries forward as the session's context;
151    /// `None` when compaction failed (history unchanged) or produced no
152    /// displayable text.
153    CompactEnd {
154        #[serde(default)]
155        summary: Option<String>,
156    },
157    TurnStart {
158        turn_id: Id,
159    },
160    TurnPause {
161        turn_id: Id,
162        reason: TurnPauseReason,
163    },
164    /// The turn resumed after a `TurnPause` (e.g. the approval was answered
165    /// or a steer arrived). Closes the pause bracket so clients never have to
166    /// infer resumption from the next tool event.
167    TurnResume {
168        turn_id: Id,
169    },
170    TurnEnd {
171        turn_id: Id,
172        status: TurnEndStatus,
173        /// Number of turn-loop steps attempted before the turn ended.
174        #[serde(default)]
175        steps: usize,
176    },
177    UsageUpdate {
178        usage: Usage,
179        /// Context-window occupancy for the root session, pre-calculated in core.
180        /// `None` before the first response or when the model's context limit is
181        /// unverified (so clients never render a confidently-wrong percentage).
182        #[serde(default, skip_serializing_if = "Option::is_none")]
183        context: Option<ContextWindow>,
184    },
185    /// Answer to [`Op::ContextReport`]: a per-category breakdown of the
186    /// session's context-window occupancy at the time of the request.
187    ContextReport(ContextBreakdown),
188    /// An ephemeral ambient hint produced off the main conversation (a predicted
189    /// "thinking phrase" for the draft, or a suggested next prompt — see
190    /// [`AmbientKind`]). Never persisted to the event log. `req_id` lets clients
191    /// drop superseded results.
192    Ambient {
193        kind: AmbientKind,
194        req_id: u64,
195        text: String,
196    },
197    Goodbye,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub enum TurnPauseReason {
202    Approval { tools: Vec<ToolUse>, message: String },
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
206pub enum SessionEndReason {
207    /// The session was replaced by a new or resumed session.
208    Replaced,
209    /// The daemon is shutting down.
210    Shutdown,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub enum TurnEndStatus {
215    Completed,
216    Interrupted {
217        #[serde(skip_serializing_if = "Option::is_none")]
218        reason: Option<String>,
219    },
220    Error {
221        /// Stable machine-readable LLM error kind. Absent for non-LLM errors
222        /// and events produced by older daemons. Notably `"oauth"` means the
223        /// provider's sign-in is missing, expired, or revoked, and only
224        /// re-authenticating can recover; clients may offer their sign-in
225        /// flow for the session's provider.
226        #[serde(default, skip_serializing_if = "Option::is_none")]
227        kind: Option<String>,
228        /// One-line summary. For a classified LLM failure this is the semantic
229        /// error kind, e.g. "rate limited"; otherwise the top of the error chain.
230        headline: String,
231        /// Expanded cause shown as indented child rows beneath the headline,
232        /// e.g. ["HTTP 400 Bad Request", "<server-provided message>"]. May be
233        /// empty when there is nothing useful to add.
234        details: Vec<String>,
235    },
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct ToolUpdate {
240    pub tool_use_id: String,
241    pub seq: u64,
242    pub message: String,
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
246pub enum ToolEndStatus {
247    Completed,
248    Cancelled,
249    Denied,
250    Failed,
251}
252
253impl ToolEndStatus {
254    /// Whether this terminal status represents an error result. Mirrors the
255    /// LLM-facing `ToolResult::is_error`: anything but a clean completion
256    /// (failure, denial, cancellation) is an error.
257    pub fn is_error(self) -> bool {
258        !matches!(self, ToolEndStatus::Completed)
259    }
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct ToolEnd {
264    pub tool_use_id: String,
265    pub status: ToolEndStatus,
266    pub result_json: serde_json::Value,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub enum ReviewDecision {
271    Accept,
272    Skip,
273    AcceptForSession,
274    /// Approve and persist an allow rule to settings.json so the same call is
275    /// auto-approved across future sessions ("always allow").
276    AcceptAlways,
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct SkillMetadata {
281    pub name: String,
282    pub description: Option<String>,
283    pub scope: Scope,
284    pub argument_hint: Option<String>,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct SubagentMetadata {
289    pub name: String,
290    pub description: String,
291    pub scope: Scope,
292}
293
294/// The provider a session resolved to.
295///
296/// Carries only what a client cannot look up for itself: the id to key state
297/// on, a name to show a user, and the endpoint actually in use — which env
298/// overrides can move away from the published default, so it is a property of
299/// this session rather than of the provider. The provider's model list is not
300/// here; it is the same for every session and is published by `ante catalog`.
301/// Unknown fields are ignored, so payloads still carrying it decode fine.
302#[derive(Debug, Clone, Default, Serialize, Deserialize)]
303pub struct ProviderSpec {
304    #[serde(alias = "name")]
305    pub id: String,
306    pub display_name: String,
307    pub base_url: String,
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct SessionInitialized {
312    pub model: ModelSpec,
313    pub provider: ProviderSpec,
314    pub session_id: Id,
315    pub cwd: PathBuf,
316    pub permission_mode: PermissionMode,
317}
318
319/// Partial update to a live session's mutable state. Each field is optional so
320/// a caller patches only what changed; absent fields are left untouched.
321/// Catalog-dependent fields are resolved before the update takes effect.
322#[derive(Debug, Clone, Default, Serialize, Deserialize)]
323pub struct SessionUpdate {
324    /// Model change, taking effect on the next turn. The spec carries the
325    /// whole request, `effort` included: a set `effort` overrides the
326    /// catalog default; unset fields resolve from the catalog.
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub model: Option<ModelSpec>,
329    /// Permission mode change, taking effect on the next turn without
330    /// aborting an in-flight one.
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub permission_mode: Option<PermissionMode>,
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct ExtensionRefreshed {
337    pub session_id: Id,
338    pub skills: Vec<SkillMetadata>,
339    pub subagents: Vec<SubagentMetadata>,
340    #[serde(default)]
341    pub mcp_servers: Vec<McpServerInfo>,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize)]
345pub struct McpServerInfo {
346    pub name: String,
347    pub command: String,
348    pub args: Vec<String>,
349    pub tools: Vec<McpToolInfo>,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct McpToolInfo {
354    pub name: String,
355    pub qualified_name: String,
356    pub description: String,
357    pub parameters: Vec<McpToolParam>,
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize)]
361pub struct McpToolParam {
362    pub name: String,
363    pub param_type: String,
364    pub required: bool,
365    pub description: String,
366}
367
368/// A patch of session overrides produced by every caller (CLI, TUI, gateway,
369/// external `serve` clients). `None` means "leave unchanged" for every field —
370/// there is exactly one meaning, regardless of who built the value.
371///
372/// This is the wire payload of [`Op::StartSession`]: the requested session
373/// configuration. Unset fields fall back to the daemon's defaults.
374#[derive(Debug, Clone, Default, Serialize, Deserialize)]
375pub struct SessionOverrides {
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub model: Option<String>,
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub provider: Option<String>,
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub permission_mode: Option<PermissionMode>,
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    pub system_prompt: Option<String>,
384    #[serde(default, skip_serializing_if = "Option::is_none")]
385    pub append_system_prompt: Option<String>,
386    /// Exactly these tools, replacing the default tool set as the base.
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub tools: Option<Vec<String>>,
389    /// Tools added on top of the base set (`tools`, or the default set).
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub include_tools: Option<Vec<String>>,
392    /// Tools removed from the session; wins over `tools` and `include_tools`.
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    pub exclude_tools: Option<Vec<String>>,
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub cwd: Option<PathBuf>,
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub effort: Option<Effort>,
399    #[serde(default, skip_serializing_if = "Option::is_none")]
400    pub enable_auto_memory: Option<bool>,
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub short_prompt: Option<bool>,
403    /// When true, the session loads no skills: none are discovered,
404    /// advertised, or invocable.
405    #[serde(default, skip_serializing_if = "Option::is_none")]
406    pub no_skills: Option<bool>,
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
410pub struct MalformedToolArgs {
411    /// Exact argument text emitted by the model before JSON decoding failed.
412    pub raw: String,
413    /// JSON decoder diagnostic. This must not contain the raw argument text.
414    pub error: String,
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
418pub struct ToolUse {
419    pub id: String,
420    pub name: String,
421    pub args: serde_json::Value,
422    /// Present when `args` could not be decoded from the model's raw JSON.
423    /// Such a call is non-executable and must be returned as an error result.
424    #[serde(default, skip_serializing_if = "Option::is_none")]
425    pub malformed_args: Option<MalformedToolArgs>,
426    #[serde(skip_serializing_if = "Option::is_none")]
427    pub signature: Option<String>,
428}
429
430impl ToolUse {
431    /// A well-formed call: decoded `args`, no malformed metadata, no signature.
432    pub fn new(id: impl Into<String>, name: impl Into<String>, args: serde_json::Value) -> Self {
433        Self { id: id.into(), name: name.into(), args, malformed_args: None, signature: None }
434    }
435}
436
437#[derive(Debug, Clone, Default, Serialize, Deserialize)]
438pub struct ModelSpec {
439    #[serde(alias = "name")]
440    pub id: String,
441    #[serde(skip_serializing_if = "Option::is_none")]
442    pub display_name: Option<String>,
443    #[serde(skip_serializing_if = "Option::is_none")]
444    pub description: Option<String>,
445    #[serde(skip_serializing_if = "Option::is_none")]
446    pub temperature: Option<f32>,
447    #[serde(skip_serializing_if = "Option::is_none")]
448    pub top_p: Option<f32>,
449    #[serde(skip_serializing_if = "Option::is_none")]
450    pub top_k: Option<u32>,
451    #[serde(skip_serializing_if = "Option::is_none")]
452    pub max_tokens: Option<u32>,
453    #[serde(skip_serializing_if = "Option::is_none")]
454    pub stop_sequences: Option<Vec<String>>,
455    #[serde(skip_serializing_if = "Option::is_none")]
456    pub context_limit: Option<u32>,
457    #[serde(skip_serializing_if = "Option::is_none")]
458    pub effort: Option<Effort>,
459    #[serde(skip_serializing_if = "Option::is_none")]
460    pub support_vision: Option<bool>,
461    #[serde(skip_serializing_if = "Option::is_none")]
462    pub weight_class: Option<WeightClass>,
463}
464
465/// Requested output/reasoning effort for model turns, on an ordinal scale.
466///
467/// `min` is the lowest effort the model supports — thinking is disabled where
468/// the model allows that; models with always-on reasoning clamp to their
469/// lowest level. Providers that expose fewer levels round a requested effort
470/// down to the nearest supported one. Variants are declared in ascending
471/// order so the derived `Ord` sorts `Min < Low < ... < Max`.
472#[derive(Debug, Clone, Serialize, Deserialize, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
473#[serde(rename_all = "lowercase")]
474pub enum Effort {
475    Min,
476    Low,
477    Medium,
478    High,
479    XHigh,
480    Max,
481}
482
483impl Effort {
484    /// All levels in ascending order.
485    pub const ALL: [Effort; 6] =
486        [Effort::Min, Effort::Low, Effort::Medium, Effort::High, Effort::XHigh, Effort::Max];
487
488    /// The wire token for this level (`"min"`, `"low"`, ..., `"max"`).
489    pub fn as_str(self) -> &'static str {
490        match self {
491            Effort::Min => "min",
492            Effort::Low => "low",
493            Effort::Medium => "medium",
494            Effort::High => "high",
495            Effort::XHigh => "xhigh",
496            Effort::Max => "max",
497        }
498    }
499}
500
501impl std::fmt::Display for Effort {
502    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
503        f.write_str(self.as_str())
504    }
505}
506
507impl std::str::FromStr for Effort {
508    type Err = String;
509
510    fn from_str(s: &str) -> Result<Self, Self::Err> {
511        Effort::ALL.into_iter().find(|e| e.as_str() == s).ok_or_else(|| {
512            format!("unknown effort `{s}` (expected min, low, medium, high, xhigh, max)")
513        })
514    }
515}
516
517/// Innate size/cost class of a model, set once per model in the catalog.
518///
519/// Orthogonal to the per-request [`Effort`] and to `context_limit`:
520/// a model's weight class reflects roughly how large and costly it is to run,
521/// not how hard it is asked to think on a given turn. Variants are declared in
522/// ascending order so the derived `Ord` sorts `Feather < Middle < Heavy`.
523#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
524#[serde(rename_all = "lowercase")]
525pub enum WeightClass {
526    /// Small, fast, cheap (Haiku- / GPT-nano-class).
527    Feather,
528    /// Mid workhorse (Sonnet- / GPT-mini- / Gemini-Flash-class).
529    Middle,
530    /// Largest, most capable, costliest (Opus- / GPT-5.x- / Gemini-Pro-class).
531    Heavy,
532}
533
534impl<'de> Deserialize<'de> for WeightClass {
535    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
536    where
537        D: Deserializer<'de>,
538    {
539        let value = String::deserialize(deserializer)?;
540        match value.to_ascii_lowercase().as_str() {
541            "feather" => Ok(Self::Feather),
542            "middle" => Ok(Self::Middle),
543            "heavy" => Ok(Self::Heavy),
544            _ => Err(serde::de::Error::unknown_variant(&value, &["feather", "middle", "heavy"])),
545        }
546    }
547}
548
549/// Token usage for one model response.
550///
551/// Convention, uniform across every provider mapping: `input_tokens` is the
552/// **full, cache-inclusive prompt size**. It always contains `cache_read_tokens`
553/// as a subset (verified live: OpenAI/OpenRouter/DeepSeek report it inside
554/// `prompt_tokens`; the Anthropic mapping adds it back since that API reports
555/// input net of cache). `cache_creation_tokens` is likewise inside `input_tokens`
556/// for providers that report cache writes (Anthropic); the OpenAI-style
557/// providers we use don't report writes at all. So [`Usage::total`]
558/// (`input + output`) is the context-window occupancy.
559///
560/// For **cost**, the cache buckets bill at different rates, so subtract them
561/// from the input rate instead of charging the full rate twice:
562/// `cost = (input - cache_read - cache_creation)·p_in
563///        + cache_read·p_cache_read + cache_creation·p_cache_write
564///        + output·p_out`.
565#[derive(Debug, Clone, Deserialize, Serialize, Default, Copy)]
566#[serde(default)]
567pub struct Usage {
568    /// Full prompt tokens, cache-inclusive (a superset of the two cache fields).
569    pub input_tokens: u32,
570    /// Generated output (completion) tokens.
571    pub output_tokens: u32,
572    /// Subset of `input_tokens` served from the prompt cache (cheaper rate).
573    #[serde(skip_serializing_if = "Option::is_none")]
574    pub cache_read_tokens: Option<u32>,
575    /// Subset of `input_tokens` written into the prompt cache (surcharge rate).
576    #[serde(skip_serializing_if = "Option::is_none")]
577    pub cache_creation_tokens: Option<u32>,
578}
579
580/// Context-window occupancy snapshot for the current (root) session, surfaced in
581/// the statusline. Raw measurements only — any percentage is a presentation
582/// concern derived at the edges, with no policy (e.g. auto-compaction) baked in.
583#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
584pub struct ContextWindow {
585    /// Tokens currently occupying the window (cache-inclusive input + output of
586    /// the most recent response).
587    pub used_tokens: u32,
588    /// Raw model context limit (e.g. 200_000).
589    pub limit_tokens: u32,
590}
591
592/// Per-category breakdown of context-window occupancy.
593///
594/// `used_tokens` is anchored on the provider-reported occupancy once the
595/// session has seen a model response; before that it is estimated. The
596/// per-category fields are estimates that normally sum to `used_tokens`, but
597/// estimation error can make them disagree slightly — clients should clamp
598/// rather than assume an exact identity.
599#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
600#[serde(default)]
601pub struct ContextBreakdown {
602    /// System prompt, excluding the skills and memory sections counted below.
603    pub system_prompt_tokens: u32,
604    /// Built-in tool schemas.
605    pub system_tools_tokens: u32,
606    /// MCP tool schemas.
607    pub mcp_tools_tokens: u32,
608    /// Memory content: project/user instruction files and the auto-memory prompt.
609    pub memory_tokens: u32,
610    /// The available-skills listing.
611    pub skills_tokens: u32,
612    /// Conversation messages: everything not attributed to a category above.
613    pub messages_tokens: u32,
614    /// Total context-window occupancy.
615    pub used_tokens: u32,
616    /// Model context limit. `None` when unverified, so clients never render a
617    /// confidently-wrong percentage.
618    pub limit_tokens: Option<u32>,
619    /// Tokens reserved at the top of the window; auto-compaction triggers once
620    /// occupancy grows into this reserve.
621    pub compact_buffer_tokens: u32,
622}
623
624impl Usage {
625    pub fn new(input_tokens: u32, output_tokens: u32) -> Self {
626        Self { input_tokens, output_tokens, cache_read_tokens: None, cache_creation_tokens: None }
627    }
628
629    /// Context-window occupancy: the full (cache-inclusive) prompt plus output.
630    pub fn total(&self) -> u32 {
631        self.input_tokens.saturating_add(self.output_tokens)
632    }
633}
634
635impl std::ops::Add<Usage> for Usage {
636    type Output = Usage;
637
638    fn add(self, other: Usage) -> Usage {
639        Usage {
640            input_tokens: self.input_tokens.saturating_add(other.input_tokens),
641            output_tokens: self.output_tokens.saturating_add(other.output_tokens),
642            cache_read_tokens: add_optional_u32(self.cache_read_tokens, other.cache_read_tokens),
643            cache_creation_tokens: add_optional_u32(
644                self.cache_creation_tokens,
645                other.cache_creation_tokens,
646            ),
647        }
648    }
649}
650
651fn add_optional_u32(a: Option<u32>, b: Option<u32>) -> Option<u32> {
652    match (a, b) {
653        (None, None) => None,
654        _ => Some(a.unwrap_or(0).saturating_add(b.unwrap_or(0))),
655    }
656}
657
658impl std::ops::AddAssign<Usage> for Usage {
659    fn add_assign(&mut self, other: Usage) {
660        *self = *self + other;
661    }
662}
663
664#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, Eq, PartialEq)]
665#[serde(rename_all = "snake_case")]
666pub enum PermissionMode {
667    /// Honor user rules; an unmatched call asks unless it is provably safe.
668    #[default]
669    Strict,
670    /// Honor user rules; an unmatched call runs unless it is provably
671    /// dangerous (a deliberately narrow classification).
672    Auto,
673    /// Bypass all permission checks, including user deny rules.
674    Yolo,
675}
676
677#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
678#[serde(rename_all = "kebab-case")]
679pub enum Scope {
680    Project,
681    User,
682    System,
683}
684
685#[cfg(test)]
686mod tests {
687    use super::{
688        Evt, ExtensionRefreshed, Id, ModelSpec, Op, PermissionMode, ProviderSpec,
689        SessionInitialized, SessionUpdate, ToolUse, Usage, WeightClass,
690    };
691    use std::path::PathBuf;
692
693    fn model_spec(name: &str) -> ModelSpec {
694        ModelSpec {
695            id: name.to_string(),
696            display_name: None,
697            description: None,
698            temperature: None,
699            top_p: None,
700            top_k: None,
701            max_tokens: None,
702            stop_sequences: None,
703            context_limit: None,
704            effort: None,
705            support_vision: None,
706            weight_class: None,
707        }
708    }
709
710    #[test]
711    fn tool_use_without_malformed_args_remains_backward_compatible() {
712        let tool_use: ToolUse = serde_json::from_value(serde_json::json!({
713            "id": "call-1",
714            "name": "Read",
715            "args": { "file_path": "README.md" }
716        }))
717        .unwrap();
718
719        assert!(tool_use.malformed_args.is_none());
720        let encoded = serde_json::to_value(tool_use).unwrap();
721        assert!(encoded.get("malformed_args").is_none());
722    }
723
724    #[test]
725    fn turn_end_error_kind_is_optional_on_the_wire() {
726        // Payloads from daemons predating the field still deserialize.
727        let old: super::TurnEndStatus = serde_json::from_value(serde_json::json!({
728            "Error": { "headline": "authentication error", "details": [] }
729        }))
730        .unwrap();
731        let super::TurnEndStatus::Error { kind, .. } = &old else {
732            panic!("expected Error variant");
733        };
734        assert!(kind.is_none());
735
736        // None is skipped, not emitted as null.
737        let json = serde_json::to_value(&old).unwrap();
738        assert!(json["Error"].get("kind").is_none());
739
740        // Some round-trips.
741        let with = super::TurnEndStatus::Error {
742            kind: Some("oauth".to_string()),
743            headline: "OAuth sign-in required".to_string(),
744            details: vec![],
745        };
746        let json = serde_json::to_value(&with).unwrap();
747        assert_eq!(json["Error"]["kind"], "oauth");
748    }
749
750    #[test]
751    fn turn_end_steps_default_for_older_events() {
752        let event = super::Evt::TurnEnd {
753            turn_id: super::Id::new("turn"),
754            status: super::TurnEndStatus::Completed,
755            steps: 3,
756        };
757        let mut json = serde_json::to_value(&event).unwrap();
758        json["TurnEnd"].as_object_mut().unwrap().remove("steps");
759        let old: super::Evt = serde_json::from_value(json).unwrap();
760
761        assert!(matches!(old, super::Evt::TurnEnd { steps: 0, .. }));
762
763        let json = serde_json::to_value(event).unwrap();
764        assert_eq!(json["TurnEnd"]["steps"], 3);
765    }
766
767    #[test]
768    fn effort_serializes_as_lowercase_tokens() {
769        let mut spec = model_spec("m");
770        spec.effort = Some(super::Effort::XHigh);
771        let json = serde_json::to_value(&spec).unwrap();
772        assert_eq!(json["effort"], "xhigh");
773
774        // None is skipped, not emitted as null.
775        let json_none = serde_json::to_value(model_spec("m")).unwrap();
776        assert!(json_none.get("effort").is_none());
777
778        // Every level round-trips through its wire token.
779        for level in super::Effort::ALL {
780            let parsed: ModelSpec =
781                serde_json::from_value(serde_json::json!({"id": "m", "effort": level.as_str()}))
782                    .unwrap();
783            assert_eq!(parsed.effort, Some(level), "round-trip {level}");
784        }
785    }
786
787    #[test]
788    fn effort_orders_min_lowest_to_max_highest() {
789        let mut sorted = super::Effort::ALL;
790        sorted.sort();
791        assert_eq!(sorted, super::Effort::ALL);
792        assert!(super::Effort::Min < super::Effort::Low);
793        assert!(super::Effort::XHigh < super::Effort::Max);
794    }
795
796    #[test]
797    fn session_overrides_effort_round_trips() {
798        let parsed: super::SessionOverrides =
799            serde_json::from_value(serde_json::json!({"effort": "max"})).unwrap();
800        assert_eq!(parsed.effort, Some(super::Effort::Max));
801
802        let parsed: super::SessionOverrides =
803            serde_json::from_value(serde_json::json!({})).unwrap();
804        assert_eq!(parsed.effort, None);
805    }
806
807    #[test]
808    fn short_prompt_round_trips_and_defaults_to_unset() {
809        let parsed: super::SessionOverrides =
810            serde_json::from_value(serde_json::json!({"short_prompt": true})).unwrap();
811        assert_eq!(parsed.short_prompt, Some(true));
812
813        let parsed: super::SessionOverrides =
814            serde_json::from_value(serde_json::json!({})).unwrap();
815        assert_eq!(parsed.short_prompt, None);
816    }
817
818    #[test]
819    fn weight_class_serializes_lowercase_and_is_omitted_when_none() {
820        let mut spec = model_spec("m");
821        spec.weight_class = Some(WeightClass::Heavy);
822        let json = serde_json::to_value(&spec).unwrap();
823        assert_eq!(json["weight_class"], "heavy");
824
825        // None is skipped, not emitted as null.
826        let json_none = serde_json::to_value(model_spec("m")).unwrap();
827        assert!(json_none.get("weight_class").is_none());
828
829        // Round-trips from the lowercase wire form.
830        let parsed: ModelSpec =
831            serde_json::from_value(serde_json::json!({"id": "m", "weight_class": "feather"}))
832                .unwrap();
833        assert_eq!(parsed.weight_class, Some(WeightClass::Feather));
834    }
835
836    #[test]
837    fn weight_class_deserializes_case_insensitively() {
838        for (value, expected) in [
839            ("Feather", WeightClass::Feather),
840            ("MIDDLE", WeightClass::Middle),
841            ("hEaVy", WeightClass::Heavy),
842        ] {
843            let parsed: ModelSpec =
844                serde_json::from_value(serde_json::json!({"id": "m", "weight_class": value}))
845                    .unwrap();
846            assert_eq!(parsed.weight_class, Some(expected));
847        }
848    }
849
850    #[test]
851    fn weight_class_orders_feather_lightest_to_heavy_heaviest() {
852        assert!(WeightClass::Feather < WeightClass::Middle);
853        assert!(WeightClass::Middle < WeightClass::Heavy);
854    }
855
856    fn provider_spec(name: &str) -> ProviderSpec {
857        ProviderSpec {
858            id: name.to_string(),
859            display_name: name.to_string(),
860            base_url: format!("https://api.{name}.test/v1"),
861        }
862    }
863
864    #[test]
865    fn compact_events_serde_roundtrip() {
866        let compact_start =
867            serde_json::to_string(&Evt::CompactStart).expect("serialize CompactStart");
868        let compact_end =
869            serde_json::to_string(&Evt::CompactEnd { summary: Some("the summary".to_string()) })
870                .expect("serialize CompactEnd");
871
872        assert_eq!(compact_start, "\"CompactStart\"");
873        assert_eq!(compact_end, r#"{"CompactEnd":{"summary":"the summary"}}"#);
874
875        assert!(matches!(
876            serde_json::from_str::<Evt>(&compact_start).expect("deserialize CompactStart"),
877            Evt::CompactStart
878        ));
879        assert!(matches!(
880            serde_json::from_str::<Evt>(&compact_end).expect("deserialize CompactEnd"),
881            Evt::CompactEnd { summary: Some(s) } if s == "the summary"
882        ));
883        assert!(matches!(
884            serde_json::from_str::<Evt>(r#"{"CompactEnd":{}}"#)
885                .expect("deserialize CompactEnd without summary"),
886            Evt::CompactEnd { summary: None }
887        ));
888    }
889
890    #[test]
891    fn session_end_and_turn_resume_serde_roundtrip() {
892        let session_id = Id::new("ses");
893        let end = Evt::SessionEnd {
894            session_id,
895            reason: super::SessionEndReason::Shutdown,
896            usage: Usage::new(10, 5),
897        };
898        let json = serde_json::to_string(&end).expect("serialize SessionEnd");
899        let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize SessionEnd");
900        assert!(matches!(
901            decoded,
902            Evt::SessionEnd { session_id: id, reason: super::SessionEndReason::Shutdown, usage }
903                if id == session_id && usage.total() == 15
904        ));
905
906        let turn_id = Id::new("op");
907        let resume = Evt::TurnResume { turn_id };
908        let json = serde_json::to_string(&resume).expect("serialize TurnResume");
909        let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize TurnResume");
910        assert!(matches!(decoded, Evt::TurnResume { turn_id: id } if id == turn_id));
911    }
912
913    #[test]
914    fn extension_refreshed_serde_roundtrip() {
915        let event = Evt::ExtensionRefreshed(Box::new(ExtensionRefreshed {
916            session_id: Id::new("ses"),
917            skills: Vec::new(),
918            subagents: Vec::new(),
919            mcp_servers: Vec::new(),
920        }));
921
922        let json = serde_json::to_string(&event).expect("serialize ExtensionRefreshed");
923        let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize ExtensionRefreshed");
924
925        assert!(matches!(
926            decoded,
927            Evt::ExtensionRefreshed(payload)
928                if payload.skills.is_empty() && payload.subagents.is_empty()
929        ));
930    }
931
932    #[test]
933    fn session_update_op_serde_roundtrip() {
934        let op = Op::UpdateSession(SessionUpdate {
935            model: Some(ModelSpec {
936                temperature: Some(0.2),
937                effort: Some(super::Effort::High),
938                ..model_spec("gpt-5.4")
939            }),
940            permission_mode: Some(PermissionMode::Yolo),
941        });
942
943        let json = serde_json::to_string(&op).expect("serialize UpdateSession");
944        let decoded = serde_json::from_str::<Op>(&json).expect("deserialize UpdateSession");
945
946        assert!(matches!(
947            decoded,
948            Op::UpdateSession(SessionUpdate {
949                model: Some(model),
950                permission_mode: Some(PermissionMode::Yolo),
951            })
952                if model.id == "gpt-5.4"
953                    && model.temperature == Some(0.2)
954                    && model.effort == Some(super::Effort::High)
955        ));
956    }
957
958    #[test]
959    fn session_updated_event_serde_roundtrip() {
960        let session_id = Id::new("ses");
961        let event = Evt::SessionUpdated(Box::new(SessionInitialized {
962            model: model_spec("claude-sonnet-4-6"),
963            provider: provider_spec("anthropic"),
964            session_id,
965            cwd: PathBuf::from("/tmp/session-updated"),
966            permission_mode: PermissionMode::default(),
967        }));
968
969        let json = serde_json::to_string(&event).expect("serialize SessionUpdated");
970        let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize SessionUpdated");
971
972        assert!(matches!(
973            decoded,
974            Evt::SessionUpdated(payload)
975                if payload.model.id == "claude-sonnet-4-6"
976                    && payload.provider.id == "anthropic"
977                    && payload.provider.base_url == "https://api.anthropic.test/v1"
978                    && payload.session_id == session_id
979                    && payload.cwd == std::path::Path::new("/tmp/session-updated")
980        ));
981    }
982
983    #[test]
984    fn provider_spec_ignores_the_dropped_model_list() {
985        // Payloads from daemons that still send the provider's model list
986        // decode against the narrowed shape.
987        let spec: ProviderSpec = serde_json::from_value(serde_json::json!({
988            "id": "anthropic",
989            "display_name": "Anthropic",
990            "base_url": "https://api.anthropic.test/v1",
991            "preferred_models": [{ "id": "claude-sonnet-4-6" }],
992        }))
993        .unwrap();
994
995        assert_eq!(spec.id, "anthropic");
996        assert_eq!(spec.display_name, "Anthropic");
997        assert_eq!(spec.base_url, "https://api.anthropic.test/v1");
998
999        // And the catalog data does not go back out.
1000        let encoded = serde_json::to_value(&spec).unwrap();
1001        assert!(encoded.get("preferred_models").is_none());
1002    }
1003
1004    #[test]
1005    fn context_report_serde_roundtrip() {
1006        let breakdown = super::ContextBreakdown {
1007            system_prompt_tokens: 1200,
1008            system_tools_tokens: 3400,
1009            mcp_tools_tokens: 0,
1010            memory_tokens: 800,
1011            skills_tokens: 150,
1012            messages_tokens: 42_000,
1013            used_tokens: 47_550,
1014            limit_tokens: Some(200_000),
1015            compact_buffer_tokens: 20_000,
1016        };
1017        let json = serde_json::to_value(Evt::ContextReport(breakdown)).expect("serialize");
1018        assert_eq!(
1019            json,
1020            serde_json::json!({
1021                "ContextReport": {
1022                    "system_prompt_tokens": 1200,
1023                    "system_tools_tokens": 3400,
1024                    "mcp_tools_tokens": 0,
1025                    "memory_tokens": 800,
1026                    "skills_tokens": 150,
1027                    "messages_tokens": 42000,
1028                    "used_tokens": 47550,
1029                    "limit_tokens": 200000,
1030                    "compact_buffer_tokens": 20000
1031                }
1032            })
1033        );
1034        let decoded = serde_json::from_value::<Evt>(json).expect("deserialize");
1035        assert!(matches!(decoded, Evt::ContextReport(b) if b == breakdown));
1036
1037        // Fields absent on the wire (older daemons) fall back to defaults.
1038        let sparse: super::ContextBreakdown =
1039            serde_json::from_value(serde_json::json!({"used_tokens": 10})).unwrap();
1040        assert_eq!(sparse.used_tokens, 10);
1041        assert_eq!(sparse.limit_tokens, None);
1042
1043        let op = serde_json::to_value(Op::ContextReport).expect("serialize op");
1044        assert_eq!(op, serde_json::json!("ContextReport"));
1045        assert!(matches!(serde_json::from_value::<Op>(op).unwrap(), Op::ContextReport));
1046    }
1047
1048    #[test]
1049    fn usage_adds_cache_fields_without_overflowing() {
1050        let mut usage = Usage {
1051            input_tokens: 10,
1052            output_tokens: 20,
1053            cache_read_tokens: Some(3),
1054            cache_creation_tokens: None,
1055        };
1056        usage += Usage {
1057            input_tokens: 5,
1058            output_tokens: 6,
1059            cache_read_tokens: Some(4),
1060            cache_creation_tokens: Some(8),
1061        };
1062
1063        assert_eq!(usage.input_tokens, 15);
1064        assert_eq!(usage.output_tokens, 26);
1065        assert_eq!(usage.total(), 41);
1066        assert_eq!(usage.cache_read_tokens, Some(7));
1067        assert_eq!(usage.cache_creation_tokens, Some(8));
1068    }
1069}