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