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/// `op` stamped with a fresh op id.
24pub fn op_msg(op: Op) -> OpMsg {
25    OpMsg { op, id: Id::op() }
26}
27
28/// `event` stamped with a fresh event id and the current time, correlated
29/// to the op it answers (`parent`), if any.
30pub fn event_msg(event: Evt, parent: Option<Id>) -> EventMsg {
31    EventMsg { timestamp: Utc::now(), id: Id::evt(), event, parent }
32}
33
34#[allow(clippy::large_enum_variant)]
35#[derive(Debug, Clone, Deserialize, Serialize)]
36pub enum Op {
37    /// Start a session from the request, replacing any running one: set
38    /// fields are pinned, unset fields resolve to the host's defaults. There
39    /// is no separate restart op — a client keeps the request it sent and
40    /// re-sends it (optionally `patched`) for `/clear` semantics.
41    StartSession(SessionRequest),
42    UpdateSession(SessionUpdate),
43    Interrupt,
44    UserInput(String),
45    ShellInput(String),
46    Steer(String),
47    ApprovalResponse {
48        turn_id: Id,
49        responses: Vec<ToolDecision>,
50    },
51    SlashCommand {
52        name: String,
53        args: String,
54    },
55    /// Continue a saved conversation from its persisted snapshot: what the
56    /// host persisted is restored, and everything the snapshot does not pin
57    /// resolves like a fresh session from the host's current defaults.
58    /// `unattended` is the resuming client's declaration, with the meaning
59    /// of `SessionRequest::unattended`; absent means false.
60    ResumeSession {
61        session_id: Id,
62        #[serde(default)]
63        unattended: bool,
64    },
65    RegisterLocalProvider {
66        port: u16,
67        model: Option<ModelSpec>,
68    },
69    RestoreLocalProvider,
70    /// Manually trigger conversation compaction on the active session.
71    /// `instructions` optionally steer the replacement summary — what to
72    /// emphasize or preserve. Ignored when the reduction needs no summary.
73    Compact {
74        #[serde(default, skip_serializing_if = "Option::is_none")]
75        instructions: Option<String>,
76    },
77    /// Request a per-category breakdown of the active session's context-window
78    /// occupancy. Answered with [`Evt::ContextReport`].
79    ContextReport,
80    /// Set, clear, or report a goal-driven execution loop on the active
81    /// session. A set goal keeps the session working — re-running turns and
82    /// judging the condition after each one — until it is met, judged
83    /// unreachable, or cleared.
84    Goal(GoalCommand),
85    /// Request an ad-hoc "thinking phrase" prediction for the in-progress
86    /// draft. Runs off the conversation critical path on a cheap model and
87    /// answers with `Evt::Ambient`. `req_id` lets the client discard stale
88    /// results when a newer request supersedes this one.
89    AmbientPhrase {
90        draft: String,
91        req_id: u64,
92    },
93    /// Request an ad-hoc next-prompt suggestion from the last exchange, shown as
94    /// input ghost text. Like [`Op::AmbientPhrase`] it runs off the critical
95    /// path on a cheap model and answers with `Evt::Ambient`. The client carries
96    /// the context (so this stays a client-only feature — headless never fires
97    /// it) and a monotonic `req_id` to drop stale results.
98    AmbientSuggestion {
99        recent_user: String,
100        recent_agent: String,
101        req_id: u64,
102    },
103    Shutdown,
104}
105
106/// A `/goal` sub-command carried by [`Op::Goal`].
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
108pub enum GoalCommand {
109    /// Set (or replace) the active goal condition.
110    Set(String),
111    /// Clear the active goal, stopping the loop.
112    Clear,
113    /// Report the current goal status.
114    Status,
115}
116
117/// Which ambient feature produced an [`Evt::Ambient`]. Both run off the main
118/// conversation on a cheap model; they differ in trigger, prompt, and sink.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
120pub enum AmbientKind {
121    /// Predicted phrase for the in-progress draft, shown in the status spinner.
122    ThinkingPhrase,
123    /// Suggested next prompt from the conversation so far, shown as input ghost text.
124    PromptSuggestion,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub enum Evt {
129    SessionStart(Box<SessionInfo>),
130    SessionUpdated(Box<SessionInfo>),
131    ExtensionRefreshed(Box<ExtensionRefreshed>),
132    /// The session span closed. Mirrors `TurnEnd`: carries the span's
133    /// identity, why it ended, and its final usage accounting.
134    SessionEnd {
135        session_id: Id,
136        reason: SessionEndReason,
137        usage: Usage,
138    },
139    UserInput(String),
140    ShellOutput {
141        command: String,
142        stdout: String,
143        stderr: String,
144        exit_code: Option<i32>,
145    },
146    AgentMessage(String),
147    Thinking(String),
148    MessageDelta(String),
149    ThinkingDelta(String),
150    Info(String),
151    /// Open a grouped Info entry with `header`. Subsequent
152    /// `InfoBlockAppend` events with the same `id` are rendered as
153    /// tree-indented child lines under it. Use for multi-step background
154    /// notifications (e.g. MCP warm-up) that should visually cluster.
155    ///
156    /// When `loading` is true the renderer appends an animated `.`/`../...`
157    /// suffix to the header until the first `InfoBlockAppend` arrives,
158    /// signalling that background work is still in flight.
159    InfoBlockStart {
160        id: String,
161        header: String,
162        #[serde(default)]
163        loading: bool,
164    },
165    /// Append a child detail line to the `InfoBlockStart` with the same `id`.
166    /// Drops silently if the matching block isn't present.
167    InfoBlockAppend {
168        id: String,
169        detail: String,
170    },
171    Error(String),
172    ToolStart(ToolUse),
173    ToolUpdate(ToolUpdate),
174    ToolEnd(ToolEnd),
175    CompactStart,
176    /// Compaction finished. `summary` is the text that replaced the
177    /// compacted history and carries forward as the session's context;
178    /// `None` when compaction failed (history unchanged) or produced no
179    /// displayable text.
180    CompactEnd {
181        #[serde(default)]
182        summary: Option<String>,
183    },
184    TurnStart {
185        turn_id: Id,
186    },
187    TurnPause {
188        turn_id: Id,
189        reason: TurnPauseReason,
190    },
191    /// The turn resumed after a `TurnPause` (e.g. the approval was answered
192    /// or a steer arrived). Closes the pause bracket so clients never have to
193    /// infer resumption from the next tool event.
194    TurnResume {
195        turn_id: Id,
196    },
197    TurnEnd {
198        turn_id: Id,
199        status: TurnEndStatus,
200        /// Number of turn-loop steps attempted before the turn ended.
201        #[serde(default)]
202        steps: usize,
203    },
204    UsageUpdate {
205        usage: Usage,
206        /// Context-window occupancy for the root session. `None` carries no
207        /// context update, including for subagent usage or an unverified model
208        /// limit. Clients retain the last snapshot until the session or model changes.
209        #[serde(default, skip_serializing_if = "Option::is_none")]
210        context: Option<ContextWindow>,
211    },
212    /// Answer to [`Op::ContextReport`]: a per-category breakdown of the
213    /// session's context-window occupancy at the time of the request.
214    ContextReport(ContextBreakdown),
215    /// An ephemeral ambient hint produced off the main conversation (a predicted
216    /// "thinking phrase" for the draft, or a suggested next prompt — see
217    /// [`AmbientKind`]). Never persisted to the event log. `req_id` lets clients
218    /// drop superseded results.
219    Ambient {
220        kind: AmbientKind,
221        req_id: u64,
222        text: String,
223    },
224    Goodbye,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub enum TurnPauseReason {
229    Approval { tools: Vec<ToolUse>, message: String },
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
233pub enum SessionEndReason {
234    /// The session was replaced by a new or resumed session.
235    Replaced,
236    /// The connection is closing: the client sent `Shutdown` or went away.
237    Shutdown,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub enum TurnEndStatus {
242    Completed,
243    Interrupted {
244        #[serde(skip_serializing_if = "Option::is_none")]
245        reason: Option<String>,
246    },
247    Error {
248        /// Stable machine-readable LLM error kind. Absent for non-LLM errors
249        /// and events produced by older daemons. Notably `"oauth"` means the
250        /// provider's sign-in is missing, expired, or revoked, and only
251        /// re-authenticating can recover; clients may offer their sign-in
252        /// flow for the session's provider.
253        #[serde(default, skip_serializing_if = "Option::is_none")]
254        kind: Option<String>,
255        /// One-line summary. For a classified LLM failure this is the semantic
256        /// error kind, e.g. "rate limited"; otherwise the top of the error chain.
257        headline: String,
258        /// Expanded cause shown as indented child rows beneath the headline,
259        /// e.g. ["HTTP 400 Bad Request", "<server-provided message>"]. May be
260        /// empty when there is nothing useful to add.
261        details: Vec<String>,
262    },
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct ToolUpdate {
267    pub tool_use_id: String,
268    pub seq: u64,
269    pub message: String,
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
273pub enum ToolEndStatus {
274    Completed,
275    Cancelled,
276    Denied,
277    Failed,
278}
279
280impl ToolEndStatus {
281    /// Whether this terminal status represents an error result. Mirrors the
282    /// LLM-facing `ToolResult::is_error`: anything but a clean completion
283    /// (failure, denial, cancellation) is an error.
284    pub fn is_error(self) -> bool {
285        !matches!(self, ToolEndStatus::Completed)
286    }
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct ToolEnd {
291    pub tool_use_id: String,
292    /// Tool name, including when execution never started. Empty in older events.
293    #[serde(default)]
294    pub tool_name: String,
295    pub status: ToolEndStatus,
296    pub result_json: serde_json::Value,
297}
298
299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
300pub enum ReviewDecision {
301    Accept,
302    Deny,
303    AcceptForSession,
304    /// Approve and persist an allow rule to settings.json so the same call is
305    /// auto-approved across future sessions ("always allow").
306    AcceptAlways,
307}
308
309/// A decision for one requested tool call.
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
311pub struct ToolDecision {
312    pub tool_use_id: String,
313    pub decision: ReviewDecision,
314    /// Optional user feedback returned to the agent with a denial.
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub message: Option<String>,
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize)]
320pub struct SkillMetadata {
321    pub name: String,
322    pub description: Option<String>,
323    pub scope: Scope,
324    pub argument_hint: Option<String>,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct SubagentMetadata {
329    pub name: String,
330    pub description: String,
331    pub scope: Scope,
332}
333
334/// The provider a session resolved to.
335///
336/// Carries only what a client cannot look up for itself: the id to key state
337/// on, a name to show a user, and the endpoint actually in use — which env
338/// overrides can move away from the published default, so it is a property of
339/// this session rather than of the provider. The provider's model list is not
340/// here; it is the same for every session and is published by `ante catalog`.
341/// Unknown fields are ignored, so payloads still carrying it decode fine.
342#[derive(Debug, Clone, Default, Serialize, Deserialize)]
343pub struct ProviderSpec {
344    #[serde(alias = "name")]
345    pub id: String,
346    pub display_name: String,
347    pub base_url: String,
348}
349
350/// A session's announced state: its identity and mutable settings
351/// (`SessionStart`, `SessionUpdated`) plus the capabilities it was equipped
352/// with, which are fixed for the session's lifetime.
353#[derive(Debug, Clone, Serialize, Deserialize)]
354pub struct SessionInfo {
355    pub model: ModelSpec,
356    pub provider: ProviderSpec,
357    pub session_id: Id,
358    pub cwd: PathBuf,
359    pub permission_mode: PermissionMode,
360    /// The skills the user can invoke in this session. Empty when absent.
361    #[serde(default)]
362    pub skills: Vec<SkillMetadata>,
363    /// The subagents this session can delegate to. Empty when absent.
364    #[serde(default)]
365    pub subagents: Vec<SubagentMetadata>,
366    /// The session's title, when one is set: a display name chosen by the
367    /// user (or the client), never derived from the conversation.
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub title: Option<String>,
370}
371
372/// Partial update to a live session's mutable state. Each field is optional so
373/// a caller patches only what changed; absent fields are left untouched.
374/// Catalog-dependent fields are resolved before the update takes effect.
375#[derive(Debug, Clone, Default, Serialize, Deserialize)]
376pub struct SessionUpdate {
377    /// Model change, taking effect on the next turn. The spec carries the
378    /// whole request, `effort` included: a set `effort` overrides the
379    /// catalog default; unset fields resolve from the catalog.
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub model: Option<ModelSpec>,
382    /// Permission mode change, taking effect on the next turn without
383    /// aborting an in-flight one.
384    #[serde(default, skip_serializing_if = "Option::is_none")]
385    pub permission_mode: Option<PermissionMode>,
386    /// Rename the session. The text is trimmed; an empty (or whitespace-only)
387    /// text clears the title.
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub title: Option<String>,
390}
391
392/// The session's MCP servers and their tools, sent for `session_id` as the
393/// servers come online. `skills` and `subagents` repeat the lists the
394/// session's `SessionStart` announced.
395#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct ExtensionRefreshed {
397    pub session_id: Id,
398    pub skills: Vec<SkillMetadata>,
399    pub subagents: Vec<SubagentMetadata>,
400    #[serde(default)]
401    pub mcp_servers: Vec<McpServerInfo>,
402}
403
404#[derive(Debug, Clone, Serialize, Deserialize)]
405pub struct McpServerInfo {
406    pub name: String,
407    pub command: String,
408    pub args: Vec<String>,
409    pub tools: Vec<McpToolInfo>,
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize)]
413pub struct McpToolInfo {
414    pub name: String,
415    pub qualified_name: String,
416    pub description: String,
417    pub parameters: Vec<McpToolParam>,
418}
419
420#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct McpToolParam {
422    pub name: String,
423    pub param_type: String,
424    pub required: bool,
425    pub description: String,
426}
427
428/// The requested session configuration — the payload of [`Op::StartSession`].
429/// A set field is pinned: it wins over every default. An unset field means
430/// "the host's default for this, now": the daemon fills it from the user's
431/// settings (re-read at the session boundary) or its built-in default. There
432/// is exactly one meaning, regardless of who built the value — never "leave
433/// unchanged".
434#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
435pub struct SessionRequest {
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    pub model: Option<String>,
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    pub provider: Option<String>,
440    #[serde(default, skip_serializing_if = "Option::is_none")]
441    pub permission_mode: Option<PermissionMode>,
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    pub system_prompt: Option<String>,
444    #[serde(default, skip_serializing_if = "Option::is_none")]
445    pub append_system_prompt: Option<String>,
446    /// Exactly these tools, replacing the default tool set as the base.
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    pub tools: Option<Vec<String>>,
449    /// Tools added on top of the base set (`tools`, or the default set).
450    #[serde(default, skip_serializing_if = "Option::is_none")]
451    pub include_tools: Option<Vec<String>>,
452    /// Tools removed from the session; wins over `tools` and `include_tools`.
453    #[serde(default, skip_serializing_if = "Option::is_none")]
454    pub exclude_tools: Option<Vec<String>>,
455    #[serde(default, skip_serializing_if = "Option::is_none")]
456    pub cwd: Option<PathBuf>,
457    #[serde(default, skip_serializing_if = "Option::is_none")]
458    pub effort: Option<Effort>,
459    #[serde(default, skip_serializing_if = "Option::is_none")]
460    pub enable_auto_memory: Option<bool>,
461    #[serde(default, skip_serializing_if = "Option::is_none")]
462    pub short_prompt: Option<bool>,
463    /// When true, the session loads no skills: none are discovered,
464    /// advertised, or invocable.
465    #[serde(default, skip_serializing_if = "Option::is_none")]
466    pub no_skills: Option<bool>,
467    /// Skills added to the default set; names match exactly. Does not enable
468    /// skill loading when disabled.
469    #[serde(default, skip_serializing_if = "Option::is_none")]
470    pub include_skills: Option<Vec<String>>,
471    /// Skills removed from the session; names match exactly. Wins over
472    /// `include_skills`.
473    #[serde(default, skip_serializing_if = "Option::is_none")]
474    pub exclude_skills: Option<Vec<String>>,
475    /// Whether the session writes a transcript and a resumable snapshot.
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub save_session: Option<bool>,
478    /// Whether no one can answer an approval prompt for this session. When
479    /// true, a tool call that would pause the turn for approval is denied
480    /// instead of pausing. Absent means false.
481    #[serde(default, skip_serializing_if = "Option::is_none")]
482    pub unattended: Option<bool>,
483    /// A title for the session (see `SessionUpdate::title` for the rules).
484    #[serde(default, skip_serializing_if = "Option::is_none")]
485    pub title: Option<String>,
486}
487
488impl SessionRequest {
489    /// Fold `patch` onto `self`, field by field: a set field in the patch
490    /// wins, an unset one keeps `self`'s value. This is the rule for every
491    /// request-over-request combination (e.g. a client retargeting the
492    /// request it keeps for `/clear`). `patch` is destructured exhaustively
493    /// (no `..` rest) so adding a field fails to compile here until its fold
494    /// rule is decided.
495    pub fn patched(self, patch: SessionRequest) -> SessionRequest {
496        let SessionRequest {
497            model,
498            provider,
499            permission_mode,
500            system_prompt,
501            append_system_prompt,
502            tools,
503            include_tools,
504            exclude_tools,
505            cwd,
506            effort,
507            enable_auto_memory,
508            short_prompt,
509            no_skills,
510            include_skills,
511            exclude_skills,
512            save_session,
513            unattended,
514            title,
515        } = patch;
516        SessionRequest {
517            model: model.or(self.model),
518            provider: provider.or(self.provider),
519            permission_mode: permission_mode.or(self.permission_mode),
520            system_prompt: system_prompt.or(self.system_prompt),
521            append_system_prompt: append_system_prompt.or(self.append_system_prompt),
522            tools: tools.or(self.tools),
523            include_tools: include_tools.or(self.include_tools),
524            exclude_tools: exclude_tools.or(self.exclude_tools),
525            cwd: cwd.or(self.cwd),
526            effort: effort.or(self.effort),
527            enable_auto_memory: enable_auto_memory.or(self.enable_auto_memory),
528            short_prompt: short_prompt.or(self.short_prompt),
529            no_skills: no_skills.or(self.no_skills),
530            include_skills: include_skills.or(self.include_skills),
531            exclude_skills: exclude_skills.or(self.exclude_skills),
532            save_session: save_session.or(self.save_session),
533            unattended: unattended.or(self.unattended),
534            title: title.or(self.title),
535        }
536    }
537}
538
539#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
540pub struct MalformedToolArgs {
541    /// Exact argument text emitted by the model for the undecodable call.
542    pub raw: String,
543    /// Decode diagnostic. This must not contain the raw argument text.
544    pub error: String,
545}
546
547/// Sentinel [`ToolUse::name`] for a call whose stream never delivered a
548/// function name; always paired with `malformed_args`.
549pub const MISSING_TOOL_NAME: &str = "missing_function_name";
550
551#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
552pub struct ToolUse {
553    pub id: String,
554    pub name: String,
555    pub args: serde_json::Value,
556    /// Present when the model's raw call could not be decoded into an
557    /// executable call: `args` that were not valid JSON, or a stream that
558    /// never delivered the function name (`name` is then
559    /// [`MISSING_TOOL_NAME`]). Such a call is non-executable and must be
560    /// returned as an error result.
561    #[serde(default, skip_serializing_if = "Option::is_none")]
562    pub malformed_args: Option<MalformedToolArgs>,
563    #[serde(skip_serializing_if = "Option::is_none")]
564    pub signature: Option<String>,
565}
566
567impl ToolUse {
568    /// A well-formed call: decoded `args`, no malformed metadata, no signature.
569    pub fn new(id: impl Into<String>, name: impl Into<String>, args: serde_json::Value) -> Self {
570        Self { id: id.into(), name: name.into(), args, malformed_args: None, signature: None }
571    }
572}
573
574#[derive(Debug, Clone, Default, Serialize, Deserialize)]
575pub struct ModelSpec {
576    #[serde(alias = "name")]
577    pub id: String,
578    #[serde(skip_serializing_if = "Option::is_none")]
579    pub display_name: Option<String>,
580    #[serde(skip_serializing_if = "Option::is_none")]
581    pub description: Option<String>,
582    #[serde(skip_serializing_if = "Option::is_none")]
583    pub temperature: Option<f32>,
584    #[serde(skip_serializing_if = "Option::is_none")]
585    pub top_p: Option<f32>,
586    #[serde(skip_serializing_if = "Option::is_none")]
587    pub top_k: Option<u32>,
588    #[serde(skip_serializing_if = "Option::is_none")]
589    pub max_tokens: Option<u32>,
590    #[serde(skip_serializing_if = "Option::is_none")]
591    pub stop_sequences: Option<Vec<String>>,
592    #[serde(skip_serializing_if = "Option::is_none")]
593    pub context_limit: Option<u32>,
594    #[serde(skip_serializing_if = "Option::is_none")]
595    pub effort: Option<Effort>,
596    /// The effort levels this model supports when configured in the user catalog.
597    /// When absent, the provider's built-in model profile supplies the ladder;
598    /// an empty list means that the model takes no effort setting.
599    #[serde(skip_serializing_if = "Option::is_none")]
600    pub supported_efforts: Option<Vec<Effort>>,
601    #[serde(skip_serializing_if = "Option::is_none")]
602    pub support_vision: Option<bool>,
603    #[serde(skip_serializing_if = "Option::is_none")]
604    pub weight_class: Option<WeightClass>,
605}
606
607/// Requested output/reasoning effort for model turns, on an ordinal scale.
608///
609/// `min` is the lowest effort the model supports — thinking is disabled where
610/// the model allows that; models with always-on reasoning clamp to their
611/// lowest level. Providers that expose fewer levels round a requested effort
612/// down to the nearest supported one. Variants are declared in ascending
613/// order so the derived `Ord` sorts `Min < Low < ... < Max`.
614#[derive(Debug, Clone, Serialize, Deserialize, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
615#[serde(rename_all = "lowercase")]
616pub enum Effort {
617    Min,
618    Low,
619    Medium,
620    High,
621    XHigh,
622    Max,
623}
624
625impl Effort {
626    /// All levels in ascending order.
627    pub const ALL: [Effort; 6] =
628        [Effort::Min, Effort::Low, Effort::Medium, Effort::High, Effort::XHigh, Effort::Max];
629
630    /// The wire token for this level (`"min"`, `"low"`, ..., `"max"`).
631    pub fn as_str(self) -> &'static str {
632        match self {
633            Effort::Min => "min",
634            Effort::Low => "low",
635            Effort::Medium => "medium",
636            Effort::High => "high",
637            Effort::XHigh => "xhigh",
638            Effort::Max => "max",
639        }
640    }
641}
642
643impl std::fmt::Display for Effort {
644    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
645        f.write_str(self.as_str())
646    }
647}
648
649impl std::str::FromStr for Effort {
650    type Err = String;
651
652    fn from_str(s: &str) -> Result<Self, Self::Err> {
653        Effort::ALL.into_iter().find(|e| e.as_str() == s).ok_or_else(|| {
654            format!("unknown effort `{s}` (expected min, low, medium, high, xhigh, max)")
655        })
656    }
657}
658
659/// Innate size/cost class of a model, set once per model in the catalog.
660///
661/// Orthogonal to the per-request [`Effort`] and to `context_limit`:
662/// a model's weight class reflects roughly how large and costly it is to run,
663/// not how hard it is asked to think on a given turn. Variants are declared in
664/// ascending order so the derived `Ord` sorts `Feather < Middle < Heavy`.
665#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
666#[serde(rename_all = "lowercase")]
667pub enum WeightClass {
668    /// Small, fast, cheap (Haiku- / GPT-nano-class).
669    Feather,
670    /// Mid workhorse (Sonnet- / GPT-mini- / Gemini-Flash-class).
671    Middle,
672    /// Largest, most capable, costliest (Opus- / GPT-5.x- / Gemini-Pro-class).
673    Heavy,
674}
675
676impl<'de> Deserialize<'de> for WeightClass {
677    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
678    where
679        D: Deserializer<'de>,
680    {
681        let value = String::deserialize(deserializer)?;
682        match value.to_ascii_lowercase().as_str() {
683            "feather" => Ok(Self::Feather),
684            "middle" => Ok(Self::Middle),
685            "heavy" => Ok(Self::Heavy),
686            _ => Err(serde::de::Error::unknown_variant(&value, &["feather", "middle", "heavy"])),
687        }
688    }
689}
690
691/// Token usage for one model response.
692///
693/// Convention, uniform across every provider mapping: `input_tokens` is the
694/// **full, cache-inclusive prompt size**. It always contains `cache_read_tokens`
695/// as a subset (verified live: OpenAI/OpenRouter/DeepSeek report it inside
696/// `prompt_tokens`; the Anthropic mapping adds it back since that API reports
697/// input net of cache). `cache_creation_tokens` is likewise inside `input_tokens`
698/// for providers that report cache writes (Anthropic); the OpenAI-style
699/// providers we use don't report writes at all. So [`Usage::total`]
700/// (`input + output`) is the context-window occupancy.
701///
702/// For **cost**, the cache buckets bill at different rates, so subtract them
703/// from the input rate instead of charging the full rate twice:
704/// `cost = (input - cache_read - cache_creation)·p_in
705///        + cache_read·p_cache_read + cache_creation·p_cache_write
706///        + output·p_out`.
707#[derive(Debug, Clone, Deserialize, Serialize, Default, Copy)]
708#[serde(default)]
709pub struct Usage {
710    /// Full prompt tokens, cache-inclusive (a superset of the two cache fields).
711    pub input_tokens: u32,
712    /// Generated output (completion) tokens.
713    pub output_tokens: u32,
714    /// Subset of `input_tokens` served from the prompt cache (cheaper rate).
715    #[serde(skip_serializing_if = "Option::is_none")]
716    pub cache_read_tokens: Option<u32>,
717    /// Subset of `input_tokens` written into the prompt cache (surcharge rate).
718    #[serde(skip_serializing_if = "Option::is_none")]
719    pub cache_creation_tokens: Option<u32>,
720}
721
722/// Context-window occupancy snapshot for the current (root) session, surfaced in
723/// the statusline. Raw measurements only — any percentage is a presentation
724/// concern derived at the edges, with no policy (e.g. auto-compaction) baked in.
725#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
726pub struct ContextWindow {
727    /// Tokens currently occupying the window (cache-inclusive input + output of
728    /// the most recent response).
729    pub used_tokens: u32,
730    /// Raw model context limit (e.g. 200_000).
731    pub limit_tokens: u32,
732}
733
734/// Per-category breakdown of context-window occupancy.
735///
736/// `used_tokens` is anchored on the provider-reported occupancy once the
737/// session has seen a model response; before that it is estimated. The
738/// per-category fields are estimates that normally sum to `used_tokens`, but
739/// estimation error can make them disagree slightly — clients should clamp
740/// rather than assume an exact identity.
741#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
742#[serde(default)]
743pub struct ContextBreakdown {
744    /// System prompt, excluding the skills and memory sections counted below.
745    pub system_prompt_tokens: u32,
746    /// Built-in tool schemas.
747    pub system_tools_tokens: u32,
748    /// MCP tool schemas.
749    pub mcp_tools_tokens: u32,
750    /// Memory content: project/user instruction files and the auto-memory prompt.
751    pub memory_tokens: u32,
752    /// The available-skills listing.
753    pub skills_tokens: u32,
754    /// Conversation messages: everything not attributed to a category above.
755    pub messages_tokens: u32,
756    /// Total context-window occupancy.
757    pub used_tokens: u32,
758    /// Model context limit. `None` when unverified, so clients never render a
759    /// confidently-wrong percentage.
760    pub limit_tokens: Option<u32>,
761    /// Tokens reserved at the top of the window; auto-compaction triggers once
762    /// occupancy grows into this reserve.
763    pub compact_buffer_tokens: u32,
764}
765
766impl Usage {
767    pub fn new(input_tokens: u32, output_tokens: u32) -> Self {
768        Self { input_tokens, output_tokens, cache_read_tokens: None, cache_creation_tokens: None }
769    }
770
771    /// Context-window occupancy: the full (cache-inclusive) prompt plus output.
772    pub fn total(&self) -> u32 {
773        self.input_tokens.saturating_add(self.output_tokens)
774    }
775}
776
777impl std::ops::Add<Usage> for Usage {
778    type Output = Usage;
779
780    fn add(self, other: Usage) -> Usage {
781        Usage {
782            input_tokens: self.input_tokens.saturating_add(other.input_tokens),
783            output_tokens: self.output_tokens.saturating_add(other.output_tokens),
784            cache_read_tokens: add_optional_u32(self.cache_read_tokens, other.cache_read_tokens),
785            cache_creation_tokens: add_optional_u32(
786                self.cache_creation_tokens,
787                other.cache_creation_tokens,
788            ),
789        }
790    }
791}
792
793fn add_optional_u32(a: Option<u32>, b: Option<u32>) -> Option<u32> {
794    match (a, b) {
795        (None, None) => None,
796        _ => Some(a.unwrap_or(0).saturating_add(b.unwrap_or(0))),
797    }
798}
799
800impl std::ops::AddAssign<Usage> for Usage {
801    fn add_assign(&mut self, other: Usage) {
802        *self = *self + other;
803    }
804}
805
806#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, Eq, PartialEq)]
807#[serde(rename_all = "snake_case")]
808pub enum PermissionMode {
809    /// Honor user rules; an unmatched call asks unless it is provably safe.
810    #[default]
811    Strict,
812    /// Honor user rules; an unmatched call runs unless it is provably
813    /// dangerous (a deliberately narrow classification).
814    Auto,
815    /// Bypass all permission checks, including user deny rules.
816    Yolo,
817}
818
819#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
820#[serde(rename_all = "kebab-case")]
821pub enum Scope {
822    Project,
823    User,
824    System,
825}
826
827#[cfg(test)]
828mod tests {
829    use super::{
830        Effort, Evt, ExtensionRefreshed, Id, ModelSpec, Op, PermissionMode, ProviderSpec,
831        ReviewDecision, SessionInfo, SessionRequest, SessionUpdate, ToolDecision, ToolEnd,
832        ToolEndStatus, ToolUse, Usage, WeightClass, event_msg, op_msg,
833    };
834    use std::path::PathBuf;
835
836    #[test]
837    fn op_msg_assigns_runtime_id() {
838        let msg = op_msg(Op::Interrupt);
839
840        assert!(msg.id.to_string().starts_with("op_"));
841        assert!(matches!(msg.op, Op::Interrupt));
842    }
843
844    #[test]
845    fn event_msg_assigns_runtime_metadata() {
846        let event = event_msg(Evt::Info("hello".to_string()), None);
847
848        assert!(event.id.to_string().starts_with("evt_"));
849        assert!(matches!(event.event, Evt::Info(message) if message == "hello"));
850        assert!(event.parent.is_none());
851    }
852
853    fn model_spec(name: &str) -> ModelSpec {
854        ModelSpec {
855            id: name.to_string(),
856            display_name: None,
857            description: None,
858            temperature: None,
859            top_p: None,
860            top_k: None,
861            max_tokens: None,
862            stop_sequences: None,
863            context_limit: None,
864            effort: None,
865            supported_efforts: None,
866            support_vision: None,
867            weight_class: None,
868        }
869    }
870
871    #[test]
872    fn tool_use_without_malformed_args_remains_backward_compatible() {
873        let tool_use: ToolUse = serde_json::from_value(serde_json::json!({
874            "id": "call-1",
875            "name": "Read",
876            "args": { "file_path": "README.md" }
877        }))
878        .unwrap();
879
880        assert!(tool_use.malformed_args.is_none());
881        let encoded = serde_json::to_value(tool_use).unwrap();
882        assert!(encoded.get("malformed_args").is_none());
883    }
884
885    #[test]
886    fn tool_end_without_name_remains_backward_compatible() {
887        let legacy = serde_json::json!({
888            "tool_use_id": "call-1",
889            "status": "Denied",
890            "result_json": "Tool call denied by policy and was not executed."
891        });
892        let mut end: ToolEnd = serde_json::from_value(legacy.clone()).unwrap();
893        assert!(end.tool_name.is_empty());
894        assert_eq!(end.status, ToolEndStatus::Denied);
895
896        end.tool_name = "Bash".to_string();
897        let mut expected = legacy;
898        expected["tool_name"] = serde_json::json!("Bash");
899        assert_eq!(serde_json::to_value(end).unwrap(), expected);
900    }
901
902    #[test]
903    fn turn_end_error_kind_is_optional_on_the_wire() {
904        // Payloads from daemons predating the field still deserialize.
905        let old: super::TurnEndStatus = serde_json::from_value(serde_json::json!({
906            "Error": { "headline": "authentication error", "details": [] }
907        }))
908        .unwrap();
909        let super::TurnEndStatus::Error { kind, .. } = &old else {
910            panic!("expected Error variant");
911        };
912        assert!(kind.is_none());
913
914        // None is skipped, not emitted as null.
915        let json = serde_json::to_value(&old).unwrap();
916        assert!(json["Error"].get("kind").is_none());
917
918        // Some round-trips.
919        let with = super::TurnEndStatus::Error {
920            kind: Some("oauth".to_string()),
921            headline: "OAuth sign-in required".to_string(),
922            details: vec![],
923        };
924        let json = serde_json::to_value(&with).unwrap();
925        assert_eq!(json["Error"]["kind"], "oauth");
926    }
927
928    #[test]
929    fn turn_end_steps_default_for_older_events() {
930        let event = super::Evt::TurnEnd {
931            turn_id: super::Id::new("turn"),
932            status: super::TurnEndStatus::Completed,
933            steps: 3,
934        };
935        let mut json = serde_json::to_value(&event).unwrap();
936        json["TurnEnd"].as_object_mut().unwrap().remove("steps");
937        let old: super::Evt = serde_json::from_value(json).unwrap();
938
939        assert!(matches!(old, super::Evt::TurnEnd { steps: 0, .. }));
940
941        let json = serde_json::to_value(event).unwrap();
942        assert_eq!(json["TurnEnd"]["steps"], 3);
943    }
944
945    #[test]
946    fn effort_serializes_as_lowercase_tokens() {
947        let mut spec = model_spec("m");
948        spec.effort = Some(super::Effort::XHigh);
949        let json = serde_json::to_value(&spec).unwrap();
950        assert_eq!(json["effort"], "xhigh");
951
952        // None is skipped, not emitted as null.
953        let json_none = serde_json::to_value(model_spec("m")).unwrap();
954        assert!(json_none.get("effort").is_none());
955
956        // Every level round-trips through its wire token.
957        for level in super::Effort::ALL {
958            let parsed: ModelSpec =
959                serde_json::from_value(serde_json::json!({"id": "m", "effort": level.as_str()}))
960                    .unwrap();
961            assert_eq!(parsed.effort, Some(level), "round-trip {level}");
962        }
963    }
964
965    #[test]
966    fn effort_orders_min_lowest_to_max_highest() {
967        let mut sorted = super::Effort::ALL;
968        sorted.sort();
969        assert_eq!(sorted, super::Effort::ALL);
970        assert!(super::Effort::Min < super::Effort::Low);
971        assert!(super::Effort::XHigh < super::Effort::Max);
972    }
973
974    #[test]
975    fn session_overrides_effort_round_trips() {
976        let parsed: super::SessionRequest =
977            serde_json::from_value(serde_json::json!({"effort": "max"})).unwrap();
978        assert_eq!(parsed.effort, Some(super::Effort::Max));
979
980        let parsed: super::SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
981        assert_eq!(parsed.effort, None);
982    }
983
984    #[test]
985    fn short_prompt_round_trips_and_defaults_to_unset() {
986        let parsed: super::SessionRequest =
987            serde_json::from_value(serde_json::json!({"short_prompt": true})).unwrap();
988        assert_eq!(parsed.short_prompt, Some(true));
989
990        let parsed: super::SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
991        assert_eq!(parsed.short_prompt, None);
992    }
993
994    #[test]
995    fn skill_filters_are_optional_on_the_wire_and_preserve_empty_overrides() {
996        let absent: SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
997        assert_eq!(absent.include_skills, None);
998        assert_eq!(absent.exclude_skills, None);
999        let encoded = serde_json::to_value(absent).unwrap();
1000        assert!(encoded.get("include_skills").is_none());
1001        assert!(encoded.get("exclude_skills").is_none());
1002
1003        let json = serde_json::json!({"include_skills": ["Review"], "exclude_skills": []});
1004        let parsed: SessionRequest = serde_json::from_value(json.clone()).unwrap();
1005        assert_eq!(parsed.include_skills, Some(vec!["Review".to_string()]));
1006        assert_eq!(parsed.exclude_skills, Some(Vec::new()));
1007        assert_eq!(serde_json::to_value(parsed).unwrap(), json);
1008    }
1009
1010    fn pinned_request() -> SessionRequest {
1011        SessionRequest {
1012            model: Some("base-model".to_string()),
1013            provider: Some("anthropic".to_string()),
1014            permission_mode: Some(PermissionMode::Strict),
1015            system_prompt: Some("base prompt".to_string()),
1016            cwd: Some(std::path::PathBuf::from("/base")),
1017            effort: Some(Effort::Medium),
1018            enable_auto_memory: Some(true),
1019            short_prompt: Some(true),
1020            no_skills: Some(true),
1021            include_skills: Some(vec!["review".to_string()]),
1022            exclude_skills: Some(vec!["noisy".to_string()]),
1023            save_session: Some(true),
1024            unattended: Some(true),
1025            title: Some("pinned title".to_string()),
1026            ..Default::default()
1027        }
1028    }
1029
1030    #[test]
1031    fn patched_with_an_empty_patch_keeps_every_field() {
1032        assert_eq!(pinned_request().patched(SessionRequest::default()), pinned_request());
1033    }
1034
1035    #[test]
1036    fn patched_overwrites_only_the_patch_set_fields() {
1037        let patched = pinned_request().patched(SessionRequest {
1038            model: Some("new-model".to_string()),
1039            permission_mode: Some(PermissionMode::Yolo),
1040            enable_auto_memory: Some(false),
1041            short_prompt: Some(false),
1042            include_skills: Some(Vec::new()),
1043            ..Default::default()
1044        });
1045        // Overwritten by the patch:
1046        assert_eq!(patched.model.as_deref(), Some("new-model"));
1047        assert_eq!(patched.permission_mode, Some(PermissionMode::Yolo));
1048        assert_eq!(patched.enable_auto_memory, Some(false));
1049        assert_eq!(patched.short_prompt, Some(false));
1050        assert_eq!(patched.include_skills, Some(Vec::new()));
1051        // Untouched (patch unset == keep):
1052        assert_eq!(patched.provider.as_deref(), Some("anthropic"));
1053        assert_eq!(patched.system_prompt.as_deref(), Some("base prompt"));
1054        assert_eq!(patched.effort, Some(Effort::Medium));
1055        assert_eq!(patched.save_session, Some(true));
1056        assert_eq!(patched.unattended, Some(true));
1057        assert_eq!(patched.title.as_deref(), Some("pinned title"));
1058        assert_eq!(patched.exclude_skills, Some(vec!["noisy".to_string()]));
1059    }
1060
1061    #[test]
1062    fn unattended_is_optional_on_the_wire() {
1063        // A request without the field, and a resume op written before the
1064        // field existed, both read as attended.
1065        let request: SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
1066        assert_eq!(request.unattended, None);
1067
1068        let op = Op::ResumeSession { session_id: Id::new("ses"), unattended: true };
1069        let mut json = serde_json::to_value(&op).unwrap();
1070        json["ResumeSession"].as_object_mut().unwrap().remove("unattended");
1071        let decoded: Op = serde_json::from_value(json).unwrap();
1072        assert!(matches!(decoded, Op::ResumeSession { unattended: false, .. }));
1073    }
1074
1075    #[test]
1076    fn session_initialized_title_is_optional_on_the_wire() {
1077        let mut payload = SessionInfo {
1078            model: model_spec("m"),
1079            provider: provider_spec("p"),
1080            session_id: Id::new("ses"),
1081            cwd: PathBuf::from("/tmp"),
1082            permission_mode: PermissionMode::default(),
1083            skills: vec![],
1084            subagents: vec![],
1085            title: None,
1086        };
1087
1088        let json = serde_json::to_value(&payload).unwrap();
1089        assert!(json.get("title").is_none(), "an unset title is omitted: {json}");
1090        let decoded: SessionInfo = serde_json::from_value(json).unwrap();
1091        assert_eq!(decoded.title, None);
1092
1093        payload.title = Some("fix".to_string());
1094        let json = serde_json::to_value(&payload).unwrap();
1095        assert_eq!(json["title"], "fix");
1096    }
1097
1098    #[test]
1099    fn weight_class_serializes_lowercase_and_is_omitted_when_none() {
1100        let mut spec = model_spec("m");
1101        spec.weight_class = Some(WeightClass::Heavy);
1102        let json = serde_json::to_value(&spec).unwrap();
1103        assert_eq!(json["weight_class"], "heavy");
1104
1105        // None is skipped, not emitted as null.
1106        let json_none = serde_json::to_value(model_spec("m")).unwrap();
1107        assert!(json_none.get("weight_class").is_none());
1108
1109        // Round-trips from the lowercase wire form.
1110        let parsed: ModelSpec =
1111            serde_json::from_value(serde_json::json!({"id": "m", "weight_class": "feather"}))
1112                .unwrap();
1113        assert_eq!(parsed.weight_class, Some(WeightClass::Feather));
1114    }
1115
1116    #[test]
1117    fn weight_class_deserializes_case_insensitively() {
1118        for (value, expected) in [
1119            ("Feather", WeightClass::Feather),
1120            ("MIDDLE", WeightClass::Middle),
1121            ("hEaVy", WeightClass::Heavy),
1122        ] {
1123            let parsed: ModelSpec =
1124                serde_json::from_value(serde_json::json!({"id": "m", "weight_class": value}))
1125                    .unwrap();
1126            assert_eq!(parsed.weight_class, Some(expected));
1127        }
1128    }
1129
1130    #[test]
1131    fn weight_class_orders_feather_lightest_to_heavy_heaviest() {
1132        assert!(WeightClass::Feather < WeightClass::Middle);
1133        assert!(WeightClass::Middle < WeightClass::Heavy);
1134    }
1135
1136    fn provider_spec(name: &str) -> ProviderSpec {
1137        ProviderSpec {
1138            id: name.to_string(),
1139            display_name: name.to_string(),
1140            base_url: format!("https://api.{name}.test/v1"),
1141        }
1142    }
1143
1144    #[test]
1145    fn compact_events_serde_roundtrip() {
1146        let compact_start =
1147            serde_json::to_string(&Evt::CompactStart).expect("serialize CompactStart");
1148        let compact_end =
1149            serde_json::to_string(&Evt::CompactEnd { summary: Some("the summary".to_string()) })
1150                .expect("serialize CompactEnd");
1151
1152        assert_eq!(compact_start, "\"CompactStart\"");
1153        assert_eq!(compact_end, r#"{"CompactEnd":{"summary":"the summary"}}"#);
1154
1155        assert!(matches!(
1156            serde_json::from_str::<Evt>(&compact_start).expect("deserialize CompactStart"),
1157            Evt::CompactStart
1158        ));
1159        assert!(matches!(
1160            serde_json::from_str::<Evt>(&compact_end).expect("deserialize CompactEnd"),
1161            Evt::CompactEnd { summary: Some(s) } if s == "the summary"
1162        ));
1163        assert!(matches!(
1164            serde_json::from_str::<Evt>(r#"{"CompactEnd":{}}"#)
1165                .expect("deserialize CompactEnd without summary"),
1166            Evt::CompactEnd { summary: None }
1167        ));
1168    }
1169
1170    #[test]
1171    fn compact_op_serde_roundtrip() {
1172        let plain = serde_json::to_string(&Op::Compact { instructions: None })
1173            .expect("serialize bare Compact");
1174        assert_eq!(plain, r#"{"Compact":{}}"#);
1175        assert!(matches!(
1176            serde_json::from_str::<Op>(&plain).expect("deserialize bare Compact"),
1177            Op::Compact { instructions: None }
1178        ));
1179
1180        let steered =
1181            serde_json::to_string(&Op::Compact { instructions: Some("keep dates".to_string()) })
1182                .expect("serialize steered Compact");
1183        assert_eq!(steered, r#"{"Compact":{"instructions":"keep dates"}}"#);
1184        assert!(matches!(
1185            serde_json::from_str::<Op>(&steered).expect("deserialize steered Compact"),
1186            Op::Compact { instructions: Some(text) } if text == "keep dates"
1187        ));
1188    }
1189
1190    #[test]
1191    fn session_end_and_turn_resume_serde_roundtrip() {
1192        let session_id = Id::new("ses");
1193        let end = Evt::SessionEnd {
1194            session_id,
1195            reason: super::SessionEndReason::Shutdown,
1196            usage: Usage::new(10, 5),
1197        };
1198        let json = serde_json::to_string(&end).expect("serialize SessionEnd");
1199        let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize SessionEnd");
1200        assert!(matches!(
1201            decoded,
1202            Evt::SessionEnd { session_id: id, reason: super::SessionEndReason::Shutdown, usage }
1203                if id == session_id && usage.total() == 15
1204        ));
1205
1206        let turn_id = Id::new("op");
1207        let resume = Evt::TurnResume { turn_id };
1208        let json = serde_json::to_string(&resume).expect("serialize TurnResume");
1209        let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize TurnResume");
1210        assert!(matches!(decoded, Evt::TurnResume { turn_id: id } if id == turn_id));
1211    }
1212
1213    #[test]
1214    fn extension_refreshed_serde_roundtrip() {
1215        let event = Evt::ExtensionRefreshed(Box::new(ExtensionRefreshed {
1216            session_id: Id::new("ses"),
1217            skills: Vec::new(),
1218            subagents: Vec::new(),
1219            mcp_servers: Vec::new(),
1220        }));
1221
1222        let json = serde_json::to_string(&event).expect("serialize ExtensionRefreshed");
1223        let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize ExtensionRefreshed");
1224
1225        assert!(matches!(
1226            decoded,
1227            Evt::ExtensionRefreshed(payload)
1228                if payload.skills.is_empty() && payload.subagents.is_empty()
1229        ));
1230    }
1231
1232    #[test]
1233    fn session_update_op_serde_roundtrip() {
1234        let op = Op::UpdateSession(SessionUpdate {
1235            model: Some(ModelSpec {
1236                temperature: Some(0.2),
1237                effort: Some(super::Effort::High),
1238                ..model_spec("gpt-5.4")
1239            }),
1240            permission_mode: Some(PermissionMode::Yolo),
1241            title: Some("renamed".to_string()),
1242        });
1243
1244        let json = serde_json::to_string(&op).expect("serialize UpdateSession");
1245        let decoded = serde_json::from_str::<Op>(&json).expect("deserialize UpdateSession");
1246
1247        assert!(matches!(
1248            decoded,
1249            Op::UpdateSession(SessionUpdate {
1250                model: Some(model),
1251                permission_mode: Some(PermissionMode::Yolo),
1252                title: Some(title),
1253            })
1254                if model.id == "gpt-5.4"
1255                    && model.temperature == Some(0.2)
1256                    && model.effort == Some(super::Effort::High)
1257                    && title == "renamed"
1258        ));
1259    }
1260
1261    #[test]
1262    fn approval_response_uses_named_tool_decisions() {
1263        let turn_id = Id::new("turn");
1264        let op = Op::ApprovalResponse {
1265            turn_id,
1266            responses: vec![ToolDecision {
1267                tool_use_id: "call-1".to_string(),
1268                decision: ReviewDecision::Deny,
1269                message: Some("use the read-only endpoint".to_string()),
1270            }],
1271        };
1272
1273        let json = serde_json::to_value(&op).expect("serialize ApprovalResponse");
1274        assert_eq!(
1275            json["ApprovalResponse"]["responses"],
1276            serde_json::json!([{
1277                "tool_use_id": "call-1",
1278                "decision": "Deny",
1279                "message": "use the read-only endpoint"
1280            }])
1281        );
1282
1283        let decoded = serde_json::from_value::<Op>(json).expect("deserialize ApprovalResponse");
1284        assert!(matches!(
1285            decoded,
1286            Op::ApprovalResponse { turn_id: id, responses }
1287                if id == turn_id
1288                    && responses == vec![ToolDecision {
1289                        tool_use_id: "call-1".to_string(),
1290                        decision: ReviewDecision::Deny,
1291                        message: Some("use the read-only endpoint".to_string()),
1292                    }]
1293        ));
1294    }
1295
1296    #[test]
1297    fn session_updated_event_serde_roundtrip() {
1298        let session_id = Id::new("ses");
1299        let event = Evt::SessionUpdated(Box::new(SessionInfo {
1300            model: model_spec("claude-sonnet-4-6"),
1301            provider: provider_spec("anthropic"),
1302            session_id,
1303            cwd: PathBuf::from("/tmp/session-updated"),
1304            permission_mode: PermissionMode::default(),
1305            skills: vec![],
1306            subagents: vec![],
1307            title: None,
1308        }));
1309
1310        let json = serde_json::to_string(&event).expect("serialize SessionUpdated");
1311        let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize SessionUpdated");
1312
1313        assert!(matches!(
1314            decoded,
1315            Evt::SessionUpdated(payload)
1316                if payload.model.id == "claude-sonnet-4-6"
1317                    && payload.provider.id == "anthropic"
1318                    && payload.provider.base_url == "https://api.anthropic.test/v1"
1319                    && payload.session_id == session_id
1320                    && payload.cwd == std::path::Path::new("/tmp/session-updated")
1321        ));
1322    }
1323
1324    #[test]
1325    fn provider_spec_ignores_the_dropped_model_list() {
1326        // Payloads from daemons that still send the provider's model list
1327        // decode against the narrowed shape.
1328        let spec: ProviderSpec = serde_json::from_value(serde_json::json!({
1329            "id": "anthropic",
1330            "display_name": "Anthropic",
1331            "base_url": "https://api.anthropic.test/v1",
1332            "preferred_models": [{ "id": "claude-sonnet-4-6" }],
1333        }))
1334        .unwrap();
1335
1336        assert_eq!(spec.id, "anthropic");
1337        assert_eq!(spec.display_name, "Anthropic");
1338        assert_eq!(spec.base_url, "https://api.anthropic.test/v1");
1339
1340        // And the catalog data does not go back out.
1341        let encoded = serde_json::to_value(&spec).unwrap();
1342        assert!(encoded.get("preferred_models").is_none());
1343    }
1344
1345    #[test]
1346    fn context_report_serde_roundtrip() {
1347        let breakdown = super::ContextBreakdown {
1348            system_prompt_tokens: 1200,
1349            system_tools_tokens: 3400,
1350            mcp_tools_tokens: 0,
1351            memory_tokens: 800,
1352            skills_tokens: 150,
1353            messages_tokens: 42_000,
1354            used_tokens: 47_550,
1355            limit_tokens: Some(200_000),
1356            compact_buffer_tokens: 20_000,
1357        };
1358        let json = serde_json::to_value(Evt::ContextReport(breakdown)).expect("serialize");
1359        assert_eq!(
1360            json,
1361            serde_json::json!({
1362                "ContextReport": {
1363                    "system_prompt_tokens": 1200,
1364                    "system_tools_tokens": 3400,
1365                    "mcp_tools_tokens": 0,
1366                    "memory_tokens": 800,
1367                    "skills_tokens": 150,
1368                    "messages_tokens": 42000,
1369                    "used_tokens": 47550,
1370                    "limit_tokens": 200000,
1371                    "compact_buffer_tokens": 20000
1372                }
1373            })
1374        );
1375        let decoded = serde_json::from_value::<Evt>(json).expect("deserialize");
1376        assert!(matches!(decoded, Evt::ContextReport(b) if b == breakdown));
1377
1378        // Fields absent on the wire (older daemons) fall back to defaults.
1379        let sparse: super::ContextBreakdown =
1380            serde_json::from_value(serde_json::json!({"used_tokens": 10})).unwrap();
1381        assert_eq!(sparse.used_tokens, 10);
1382        assert_eq!(sparse.limit_tokens, None);
1383
1384        let op = serde_json::to_value(Op::ContextReport).expect("serialize op");
1385        assert_eq!(op, serde_json::json!("ContextReport"));
1386        assert!(matches!(serde_json::from_value::<Op>(op).unwrap(), Op::ContextReport));
1387    }
1388
1389    #[test]
1390    fn usage_adds_cache_fields_without_overflowing() {
1391        let mut usage = Usage {
1392            input_tokens: 10,
1393            output_tokens: 20,
1394            cache_read_tokens: Some(3),
1395            cache_creation_tokens: None,
1396        };
1397        usage += Usage {
1398            input_tokens: 5,
1399            output_tokens: 6,
1400            cache_read_tokens: Some(4),
1401            cache_creation_tokens: Some(8),
1402        };
1403
1404        assert_eq!(usage.input_tokens, 15);
1405        assert_eq!(usage.output_tokens, 26);
1406        assert_eq!(usage.total(), 41);
1407        assert_eq!(usage.cache_read_tokens, Some(7));
1408        assert_eq!(usage.cache_creation_tokens, Some(8));
1409    }
1410}