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, Default, 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    /// Provider change, taking effect on the next turn: the conversation
467    /// continues on the named catalog provider, keeping the session id,
468    /// messages, title and permission state. `model` then names a model on that
469    /// provider; absent, the provider's default. Naming the current provider is
470    /// not a switch. Refused whole, answered with `Error` instead of
471    /// `SessionUpdated`, for an unknown provider id or for another provider
472    /// while a turn is in flight; never re-routed.
473    #[serde(default, skip_serializing_if = "Option::is_none")]
474    pub provider: Option<String>,
475    /// Model change, taking effect on the next turn. The spec carries the
476    /// whole request, `effort` included: a set `effort` overrides the
477    /// catalog default; unset fields resolve from the catalog. Resolved on
478    /// the session's provider — the one `provider` names when both are set.
479    #[serde(default, skip_serializing_if = "Option::is_none")]
480    pub model: Option<ModelSpec>,
481    /// Permission mode change, taking effect on the next turn without
482    /// aborting an in-flight one.
483    #[serde(default, skip_serializing_if = "Option::is_none")]
484    pub permission_mode: Option<PermissionMode>,
485    /// Rename the session. The text is trimmed; an empty (or whitespace-only)
486    /// text clears the title.
487    #[serde(default, skip_serializing_if = "Option::is_none")]
488    pub title: Option<String>,
489}
490
491/// The session's MCP servers and their tools, sent for `session_id` as the
492/// servers come online. `skills` and `subagents` repeat the lists the
493/// session's `SessionStart` announced.
494#[derive(Debug, Clone, Serialize, Deserialize)]
495pub struct ExtensionRefreshed {
496    pub session_id: Id,
497    pub skills: Vec<SkillMetadata>,
498    pub subagents: Vec<SubagentMetadata>,
499    #[serde(default)]
500    pub mcp_servers: Vec<McpServerInfo>,
501}
502
503#[derive(Debug, Clone, Serialize, Deserialize)]
504pub struct McpServerInfo {
505    pub name: String,
506    pub command: String,
507    pub args: Vec<String>,
508    pub tools: Vec<McpToolInfo>,
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize)]
512pub struct McpToolInfo {
513    pub name: String,
514    pub qualified_name: String,
515    pub description: String,
516    pub parameters: Vec<McpToolParam>,
517}
518
519#[derive(Debug, Clone, Serialize, Deserialize)]
520pub struct McpToolParam {
521    pub name: String,
522    pub param_type: String,
523    pub required: bool,
524    pub description: String,
525}
526
527/// The requested session configuration — the payload of [`Op::StartSession`].
528/// A set field is pinned: it wins over every default. An unset field means
529/// "the host's default for this, now": the daemon fills it from the user's
530/// settings (re-read at the session boundary) or its built-in default. There
531/// is exactly one meaning, regardless of who built the value — never "leave
532/// unchanged".
533#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
534pub struct SessionRequest {
535    #[serde(default, skip_serializing_if = "Option::is_none")]
536    pub model: Option<String>,
537    #[serde(default, skip_serializing_if = "Option::is_none")]
538    pub provider: Option<String>,
539    #[serde(default, skip_serializing_if = "Option::is_none")]
540    pub permission_mode: Option<PermissionMode>,
541    #[serde(default, skip_serializing_if = "Option::is_none")]
542    pub system_prompt: Option<String>,
543    #[serde(default, skip_serializing_if = "Option::is_none")]
544    pub append_system_prompt: Option<String>,
545    /// Exactly these tools, replacing the default tool set as the base.
546    #[serde(default, skip_serializing_if = "Option::is_none")]
547    pub tools: Option<Vec<String>>,
548    /// Tools added on top of the base set (`tools`, or the default set).
549    #[serde(default, skip_serializing_if = "Option::is_none")]
550    pub include_tools: Option<Vec<String>>,
551    /// Tools removed from the session; wins over `tools` and `include_tools`.
552    #[serde(default, skip_serializing_if = "Option::is_none")]
553    pub exclude_tools: Option<Vec<String>>,
554    #[serde(default, skip_serializing_if = "Option::is_none")]
555    pub cwd: Option<PathBuf>,
556    #[serde(default, skip_serializing_if = "Option::is_none")]
557    pub effort: Option<Effort>,
558    #[serde(default, skip_serializing_if = "Option::is_none")]
559    pub enable_auto_memory: Option<bool>,
560    #[serde(default, skip_serializing_if = "Option::is_none")]
561    pub short_prompt: Option<bool>,
562    /// When true, the session loads no skills: none are discovered,
563    /// advertised, or invocable.
564    #[serde(default, skip_serializing_if = "Option::is_none")]
565    pub no_skills: Option<bool>,
566    /// Skills added to the default set; names match exactly. Does not enable
567    /// skill loading when disabled.
568    #[serde(default, skip_serializing_if = "Option::is_none")]
569    pub include_skills: Option<Vec<String>>,
570    /// Skills removed from the session; names match exactly. Wins over
571    /// `include_skills`.
572    #[serde(default, skip_serializing_if = "Option::is_none")]
573    pub exclude_skills: Option<Vec<String>>,
574    /// Whether the session writes a transcript and a resumable snapshot.
575    #[serde(default, skip_serializing_if = "Option::is_none")]
576    pub save_session: Option<bool>,
577    /// Whether no one can answer an approval prompt for this session. When
578    /// true, a tool call that would pause the turn for approval is denied
579    /// instead of pausing. Absent means false.
580    #[serde(default, skip_serializing_if = "Option::is_none")]
581    pub unattended: Option<bool>,
582    /// A title for the session (see `SessionUpdate::title` for the rules).
583    #[serde(default, skip_serializing_if = "Option::is_none")]
584    pub title: Option<String>,
585}
586
587impl SessionRequest {
588    /// Fold `patch` onto `self`, field by field: a set field in the patch
589    /// wins, an unset one keeps `self`'s value. This is the rule for every
590    /// request-over-request combination (e.g. a client retargeting the
591    /// request it keeps for `/clear`). `patch` is destructured exhaustively
592    /// (no `..` rest) so adding a field fails to compile here until its fold
593    /// rule is decided.
594    pub fn patched(self, patch: SessionRequest) -> SessionRequest {
595        let SessionRequest {
596            model,
597            provider,
598            permission_mode,
599            system_prompt,
600            append_system_prompt,
601            tools,
602            include_tools,
603            exclude_tools,
604            cwd,
605            effort,
606            enable_auto_memory,
607            short_prompt,
608            no_skills,
609            include_skills,
610            exclude_skills,
611            save_session,
612            unattended,
613            title,
614        } = patch;
615        SessionRequest {
616            model: model.or(self.model),
617            provider: provider.or(self.provider),
618            permission_mode: permission_mode.or(self.permission_mode),
619            system_prompt: system_prompt.or(self.system_prompt),
620            append_system_prompt: append_system_prompt.or(self.append_system_prompt),
621            tools: tools.or(self.tools),
622            include_tools: include_tools.or(self.include_tools),
623            exclude_tools: exclude_tools.or(self.exclude_tools),
624            cwd: cwd.or(self.cwd),
625            effort: effort.or(self.effort),
626            enable_auto_memory: enable_auto_memory.or(self.enable_auto_memory),
627            short_prompt: short_prompt.or(self.short_prompt),
628            no_skills: no_skills.or(self.no_skills),
629            include_skills: include_skills.or(self.include_skills),
630            exclude_skills: exclude_skills.or(self.exclude_skills),
631            save_session: save_session.or(self.save_session),
632            unattended: unattended.or(self.unattended),
633            title: title.or(self.title),
634        }
635    }
636}
637
638#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
639pub struct MalformedToolArgs {
640    /// Exact argument text emitted by the model for the undecodable call.
641    pub raw: String,
642    /// Decode diagnostic. This must not contain the raw argument text.
643    pub error: String,
644}
645
646/// Sentinel [`ToolUse::name`] for a call whose stream never delivered a
647/// function name; always paired with `malformed_args`.
648pub const MISSING_TOOL_NAME: &str = "missing_function_name";
649
650#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
651pub struct ToolUse {
652    pub id: String,
653    pub name: String,
654    pub args: serde_json::Value,
655    /// Present when the model's raw call could not be decoded into an
656    /// executable call: `args` that were not valid JSON, or a stream that
657    /// never delivered the function name (`name` is then
658    /// [`MISSING_TOOL_NAME`]). Such a call is non-executable and must be
659    /// returned as an error result.
660    #[serde(default, skip_serializing_if = "Option::is_none")]
661    pub malformed_args: Option<MalformedToolArgs>,
662    #[serde(skip_serializing_if = "Option::is_none")]
663    pub signature: Option<String>,
664}
665
666impl ToolUse {
667    /// A well-formed call: decoded `args`, no malformed metadata, no signature.
668    pub fn new(id: impl Into<String>, name: impl Into<String>, args: serde_json::Value) -> Self {
669        Self { id: id.into(), name: name.into(), args, malformed_args: None, signature: None }
670    }
671}
672
673#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
674pub struct ModelSpec {
675    #[serde(alias = "name")]
676    pub id: String,
677    #[serde(skip_serializing_if = "Option::is_none")]
678    pub display_name: Option<String>,
679    #[serde(skip_serializing_if = "Option::is_none")]
680    pub description: Option<String>,
681    #[serde(skip_serializing_if = "Option::is_none")]
682    pub temperature: Option<f32>,
683    #[serde(skip_serializing_if = "Option::is_none")]
684    pub top_p: Option<f32>,
685    #[serde(skip_serializing_if = "Option::is_none")]
686    pub top_k: Option<u32>,
687    #[serde(skip_serializing_if = "Option::is_none")]
688    pub max_tokens: Option<u32>,
689    #[serde(skip_serializing_if = "Option::is_none")]
690    pub stop_sequences: Option<Vec<String>>,
691    #[serde(skip_serializing_if = "Option::is_none")]
692    pub context_limit: Option<u32>,
693    #[serde(skip_serializing_if = "Option::is_none")]
694    pub effort: Option<Effort>,
695    /// The effort levels this model supports when configured in the user catalog.
696    /// When absent, the provider's built-in model profile supplies the ladder;
697    /// an empty list means that the model takes no effort setting.
698    #[serde(skip_serializing_if = "Option::is_none")]
699    pub supported_efforts: Option<Vec<Effort>>,
700    #[serde(skip_serializing_if = "Option::is_none")]
701    pub support_vision: Option<bool>,
702    #[serde(skip_serializing_if = "Option::is_none")]
703    pub weight_class: Option<WeightClass>,
704}
705
706/// Requested output/reasoning effort for model turns, on an ordinal scale.
707///
708/// `min` is the lowest effort the model supports — thinking is disabled where
709/// the model allows that; models with always-on reasoning clamp to their
710/// lowest level. Providers that expose fewer levels round a requested effort
711/// down to the nearest supported one. Variants are declared in ascending
712/// order so the derived `Ord` sorts `Min < Low < ... < Max`.
713#[derive(Debug, Clone, Serialize, Deserialize, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
714#[serde(rename_all = "lowercase")]
715pub enum Effort {
716    Min,
717    Low,
718    Medium,
719    High,
720    XHigh,
721    Max,
722}
723
724impl Effort {
725    /// All levels in ascending order.
726    pub const ALL: [Effort; 6] =
727        [Effort::Min, Effort::Low, Effort::Medium, Effort::High, Effort::XHigh, Effort::Max];
728
729    /// The wire token for this level (`"min"`, `"low"`, ..., `"max"`).
730    pub fn as_str(self) -> &'static str {
731        match self {
732            Effort::Min => "min",
733            Effort::Low => "low",
734            Effort::Medium => "medium",
735            Effort::High => "high",
736            Effort::XHigh => "xhigh",
737            Effort::Max => "max",
738        }
739    }
740}
741
742impl std::fmt::Display for Effort {
743    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
744        f.write_str(self.as_str())
745    }
746}
747
748impl std::str::FromStr for Effort {
749    type Err = String;
750
751    fn from_str(s: &str) -> Result<Self, Self::Err> {
752        Effort::ALL.into_iter().find(|e| e.as_str() == s).ok_or_else(|| {
753            format!("unknown effort `{s}` (expected min, low, medium, high, xhigh, max)")
754        })
755    }
756}
757
758/// Innate size/cost class of a model, set once per model in the catalog.
759///
760/// Orthogonal to the per-request [`Effort`] and to `context_limit`:
761/// a model's weight class reflects roughly how large and costly it is to run,
762/// not how hard it is asked to think on a given turn. Variants are declared in
763/// ascending order so the derived `Ord` sorts `Feather < Middle < Heavy`.
764#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
765#[serde(rename_all = "lowercase")]
766pub enum WeightClass {
767    /// Small, fast, cheap (Haiku- / GPT-nano-class).
768    Feather,
769    /// Mid workhorse (Sonnet- / GPT-mini- / Gemini-Flash-class).
770    Middle,
771    /// Largest, most capable, costliest (Opus- / GPT-5.x- / Gemini-Pro-class).
772    Heavy,
773}
774
775impl<'de> Deserialize<'de> for WeightClass {
776    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
777    where
778        D: Deserializer<'de>,
779    {
780        let value = String::deserialize(deserializer)?;
781        match value.to_ascii_lowercase().as_str() {
782            "feather" => Ok(Self::Feather),
783            "middle" => Ok(Self::Middle),
784            "heavy" => Ok(Self::Heavy),
785            _ => Err(serde::de::Error::unknown_variant(&value, &["feather", "middle", "heavy"])),
786        }
787    }
788}
789
790/// Token usage for one model response.
791///
792/// Convention, uniform across every provider mapping: `input_tokens` is the
793/// **full, cache-inclusive prompt size**. It always contains `cache_read_tokens`
794/// as a subset (verified live: OpenAI/OpenRouter/DeepSeek report it inside
795/// `prompt_tokens`; the Anthropic mapping adds it back since that API reports
796/// input net of cache). `cache_creation_tokens` is likewise inside `input_tokens`
797/// for providers that report cache writes (Anthropic); the OpenAI-style
798/// providers we use don't report writes at all. So [`Usage::total`]
799/// (`input + output`) is the context-window occupancy.
800///
801/// For **cost**, the cache buckets bill at different rates, so subtract them
802/// from the input rate instead of charging the full rate twice:
803/// `cost = (input - cache_read - cache_creation)·p_in
804///        + cache_read·p_cache_read + cache_creation·p_cache_write
805///        + output·p_out`.
806#[derive(Debug, Clone, Deserialize, Serialize, Default, Copy)]
807#[serde(default)]
808pub struct Usage {
809    /// Full prompt tokens, cache-inclusive (a superset of the two cache fields).
810    pub input_tokens: u32,
811    /// Generated output (completion) tokens.
812    pub output_tokens: u32,
813    /// Subset of `input_tokens` served from the prompt cache (cheaper rate).
814    #[serde(skip_serializing_if = "Option::is_none")]
815    pub cache_read_tokens: Option<u32>,
816    /// Subset of `input_tokens` written into the prompt cache (surcharge rate).
817    #[serde(skip_serializing_if = "Option::is_none")]
818    pub cache_creation_tokens: Option<u32>,
819}
820
821/// Context-window occupancy snapshot for the current (root) session, surfaced in
822/// the statusline. Raw measurements only — any percentage is a presentation
823/// concern derived at the edges, with no policy (e.g. auto-compaction) baked in.
824#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
825pub struct ContextWindow {
826    /// Tokens currently occupying the window (cache-inclusive input + output of
827    /// the most recent response).
828    pub used_tokens: u32,
829    /// Raw model context limit (e.g. 200_000).
830    pub limit_tokens: u32,
831}
832
833/// Per-category breakdown of context-window occupancy.
834///
835/// `used_tokens` is anchored on the provider-reported occupancy once the
836/// session has seen a model response; before that it is estimated. The
837/// per-category fields are estimates that normally sum to `used_tokens`, but
838/// estimation error can make them disagree slightly — clients should clamp
839/// rather than assume an exact identity.
840#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
841#[serde(default)]
842pub struct ContextBreakdown {
843    /// System prompt, excluding the skills and memory sections counted below.
844    pub system_prompt_tokens: u32,
845    /// Built-in tool schemas.
846    pub system_tools_tokens: u32,
847    /// MCP tool schemas.
848    pub mcp_tools_tokens: u32,
849    /// Memory content: project/user instruction files and the auto-memory prompt.
850    pub memory_tokens: u32,
851    /// The available-skills listing.
852    pub skills_tokens: u32,
853    /// Conversation messages: everything not attributed to a category above.
854    pub messages_tokens: u32,
855    /// Total context-window occupancy.
856    pub used_tokens: u32,
857    /// Model context limit. `None` when unverified, so clients never render a
858    /// confidently-wrong percentage.
859    pub limit_tokens: Option<u32>,
860    /// Tokens reserved at the top of the window; auto-compaction triggers once
861    /// occupancy grows into this reserve.
862    pub compact_buffer_tokens: u32,
863}
864
865impl Usage {
866    pub fn new(input_tokens: u32, output_tokens: u32) -> Self {
867        Self { input_tokens, output_tokens, cache_read_tokens: None, cache_creation_tokens: None }
868    }
869
870    /// Context-window occupancy: the full (cache-inclusive) prompt plus output.
871    pub fn total(&self) -> u32 {
872        self.input_tokens.saturating_add(self.output_tokens)
873    }
874}
875
876impl std::ops::Add<Usage> for Usage {
877    type Output = Usage;
878
879    fn add(self, other: Usage) -> Usage {
880        Usage {
881            input_tokens: self.input_tokens.saturating_add(other.input_tokens),
882            output_tokens: self.output_tokens.saturating_add(other.output_tokens),
883            cache_read_tokens: add_optional_u32(self.cache_read_tokens, other.cache_read_tokens),
884            cache_creation_tokens: add_optional_u32(
885                self.cache_creation_tokens,
886                other.cache_creation_tokens,
887            ),
888        }
889    }
890}
891
892fn add_optional_u32(a: Option<u32>, b: Option<u32>) -> Option<u32> {
893    match (a, b) {
894        (None, None) => None,
895        _ => Some(a.unwrap_or(0).saturating_add(b.unwrap_or(0))),
896    }
897}
898
899impl std::ops::AddAssign<Usage> for Usage {
900    fn add_assign(&mut self, other: Usage) {
901        *self = *self + other;
902    }
903}
904
905#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, Eq, PartialEq)]
906#[serde(rename_all = "snake_case")]
907pub enum PermissionMode {
908    /// Honor user rules; an unmatched call asks unless it is provably safe.
909    #[default]
910    Strict,
911    /// Honor user rules; an unmatched call runs unless it is provably
912    /// dangerous (a deliberately narrow classification).
913    Auto,
914    /// Bypass all permission checks, including user deny rules.
915    Yolo,
916}
917
918#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
919#[serde(rename_all = "kebab-case")]
920pub enum Scope {
921    Project,
922    User,
923    System,
924}
925
926#[cfg(test)]
927mod tests {
928    use super::{
929        Effort, Evt, ExtensionRefreshed, Id, ModelSpec, Op, PermissionMode, ProviderSpec,
930        ReviewDecision, SessionInfo, SessionRequest, SessionUpdate, ToolDecision, ToolEnd,
931        ToolEndStatus, ToolUse, Usage, WeightClass, event_msg, op_msg,
932    };
933    use std::path::PathBuf;
934
935    #[test]
936    fn op_msg_assigns_runtime_id() {
937        let msg = op_msg(Op::Interrupt);
938
939        assert!(msg.id.to_string().starts_with("op_"));
940        assert!(matches!(msg.op, Op::Interrupt));
941    }
942
943    #[test]
944    fn event_msg_assigns_runtime_metadata() {
945        let event = event_msg(Evt::Info("hello".to_string()), None);
946
947        assert!(event.id.to_string().starts_with("evt_"));
948        assert!(matches!(event.event, Evt::Info(message) if message == "hello"));
949        assert!(event.parent.is_none());
950    }
951
952    fn model_spec(name: &str) -> ModelSpec {
953        ModelSpec {
954            id: name.to_string(),
955            display_name: None,
956            description: None,
957            temperature: None,
958            top_p: None,
959            top_k: None,
960            max_tokens: None,
961            stop_sequences: None,
962            context_limit: None,
963            effort: None,
964            supported_efforts: None,
965            support_vision: None,
966            weight_class: None,
967        }
968    }
969
970    #[test]
971    fn tool_use_without_malformed_args_remains_backward_compatible() {
972        let tool_use: ToolUse = serde_json::from_value(serde_json::json!({
973            "id": "call-1",
974            "name": "Read",
975            "args": { "file_path": "README.md" }
976        }))
977        .unwrap();
978
979        assert!(tool_use.malformed_args.is_none());
980        let encoded = serde_json::to_value(tool_use).unwrap();
981        assert!(encoded.get("malformed_args").is_none());
982    }
983
984    #[test]
985    fn tool_end_without_name_remains_backward_compatible() {
986        let legacy = serde_json::json!({
987            "tool_use_id": "call-1",
988            "status": "Denied",
989            "result_json": "Tool call denied by policy and was not executed."
990        });
991        let mut end: ToolEnd = serde_json::from_value(legacy.clone()).unwrap();
992        assert!(end.tool_name.is_empty());
993        assert_eq!(end.status, ToolEndStatus::Denied);
994
995        end.tool_name = "Bash".to_string();
996        let mut expected = legacy;
997        expected["tool_name"] = serde_json::json!("Bash");
998        assert_eq!(serde_json::to_value(end).unwrap(), expected);
999    }
1000
1001    #[test]
1002    fn task_end_round_trips_with_observed_or_unobserved_exit() {
1003        for exit_code in [Some(2), None] {
1004            let json = serde_json::json!({
1005                "TaskEnd": { "tool_use_id": "bash-1", "exit_code": exit_code }
1006            });
1007            let event: super::Evt = serde_json::from_value(json.clone()).unwrap();
1008            assert!(matches!(&event, super::Evt::TaskEnd { tool_use_id, exit_code: code }
1009                if tool_use_id == "bash-1" && *code == exit_code));
1010            assert_eq!(serde_json::to_value(event).unwrap(), json);
1011        }
1012    }
1013
1014    #[test]
1015    fn turn_end_error_kind_is_optional_on_the_wire() {
1016        // Payloads from daemons predating the field still deserialize.
1017        let old: super::TurnEndStatus = serde_json::from_value(serde_json::json!({
1018            "Error": { "headline": "authentication error", "details": [] }
1019        }))
1020        .unwrap();
1021        let super::TurnEndStatus::Error { kind, .. } = &old else {
1022            panic!("expected Error variant");
1023        };
1024        assert!(kind.is_none());
1025
1026        // None is skipped, not emitted as null.
1027        let json = serde_json::to_value(&old).unwrap();
1028        assert!(json["Error"].get("kind").is_none());
1029
1030        // Some round-trips.
1031        let with = super::TurnEndStatus::Error {
1032            kind: Some("oauth".to_string()),
1033            headline: "OAuth sign-in required".to_string(),
1034            details: vec![],
1035        };
1036        let json = serde_json::to_value(&with).unwrap();
1037        assert_eq!(json["Error"]["kind"], "oauth");
1038    }
1039
1040    #[test]
1041    fn turn_end_steps_default_for_older_events() {
1042        let event = super::Evt::TurnEnd {
1043            turn_id: super::Id::new("turn"),
1044            status: super::TurnEndStatus::Completed,
1045            steps: 3,
1046        };
1047        let mut json = serde_json::to_value(&event).unwrap();
1048        json["TurnEnd"].as_object_mut().unwrap().remove("steps");
1049        let old: super::Evt = serde_json::from_value(json).unwrap();
1050
1051        assert!(matches!(old, super::Evt::TurnEnd { steps: 0, .. }));
1052
1053        let json = serde_json::to_value(event).unwrap();
1054        assert_eq!(json["TurnEnd"]["steps"], 3);
1055    }
1056
1057    #[test]
1058    fn effort_serializes_as_lowercase_tokens() {
1059        let mut spec = model_spec("m");
1060        spec.effort = Some(super::Effort::XHigh);
1061        let json = serde_json::to_value(&spec).unwrap();
1062        assert_eq!(json["effort"], "xhigh");
1063
1064        // None is skipped, not emitted as null.
1065        let json_none = serde_json::to_value(model_spec("m")).unwrap();
1066        assert!(json_none.get("effort").is_none());
1067
1068        // Every level round-trips through its wire token.
1069        for level in super::Effort::ALL {
1070            let parsed: ModelSpec =
1071                serde_json::from_value(serde_json::json!({"id": "m", "effort": level.as_str()}))
1072                    .unwrap();
1073            assert_eq!(parsed.effort, Some(level), "round-trip {level}");
1074        }
1075    }
1076
1077    #[test]
1078    fn effort_orders_min_lowest_to_max_highest() {
1079        let mut sorted = super::Effort::ALL;
1080        sorted.sort();
1081        assert_eq!(sorted, super::Effort::ALL);
1082        assert!(super::Effort::Min < super::Effort::Low);
1083        assert!(super::Effort::XHigh < super::Effort::Max);
1084    }
1085
1086    #[test]
1087    fn session_overrides_effort_round_trips() {
1088        let parsed: super::SessionRequest =
1089            serde_json::from_value(serde_json::json!({"effort": "max"})).unwrap();
1090        assert_eq!(parsed.effort, Some(super::Effort::Max));
1091
1092        let parsed: super::SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
1093        assert_eq!(parsed.effort, None);
1094    }
1095
1096    #[test]
1097    fn short_prompt_round_trips_and_defaults_to_unset() {
1098        let parsed: super::SessionRequest =
1099            serde_json::from_value(serde_json::json!({"short_prompt": true})).unwrap();
1100        assert_eq!(parsed.short_prompt, Some(true));
1101
1102        let parsed: super::SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
1103        assert_eq!(parsed.short_prompt, None);
1104    }
1105
1106    #[test]
1107    fn skill_filters_are_optional_on_the_wire_and_preserve_empty_overrides() {
1108        let absent: SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
1109        assert_eq!(absent.include_skills, None);
1110        assert_eq!(absent.exclude_skills, None);
1111        let encoded = serde_json::to_value(absent).unwrap();
1112        assert!(encoded.get("include_skills").is_none());
1113        assert!(encoded.get("exclude_skills").is_none());
1114
1115        let json = serde_json::json!({"include_skills": ["Review"], "exclude_skills": []});
1116        let parsed: SessionRequest = serde_json::from_value(json.clone()).unwrap();
1117        assert_eq!(parsed.include_skills, Some(vec!["Review".to_string()]));
1118        assert_eq!(parsed.exclude_skills, Some(Vec::new()));
1119        assert_eq!(serde_json::to_value(parsed).unwrap(), json);
1120    }
1121
1122    fn pinned_request() -> SessionRequest {
1123        SessionRequest {
1124            model: Some("base-model".to_string()),
1125            provider: Some("anthropic".to_string()),
1126            permission_mode: Some(PermissionMode::Strict),
1127            system_prompt: Some("base prompt".to_string()),
1128            cwd: Some(std::path::PathBuf::from("/base")),
1129            effort: Some(Effort::Medium),
1130            enable_auto_memory: Some(true),
1131            short_prompt: Some(true),
1132            no_skills: Some(true),
1133            include_skills: Some(vec!["review".to_string()]),
1134            exclude_skills: Some(vec!["noisy".to_string()]),
1135            save_session: Some(true),
1136            unattended: Some(true),
1137            title: Some("pinned title".to_string()),
1138            ..Default::default()
1139        }
1140    }
1141
1142    #[test]
1143    fn patched_with_an_empty_patch_keeps_every_field() {
1144        assert_eq!(pinned_request().patched(SessionRequest::default()), pinned_request());
1145    }
1146
1147    #[test]
1148    fn patched_overwrites_only_the_patch_set_fields() {
1149        let patched = pinned_request().patched(SessionRequest {
1150            model: Some("new-model".to_string()),
1151            permission_mode: Some(PermissionMode::Yolo),
1152            enable_auto_memory: Some(false),
1153            short_prompt: Some(false),
1154            include_skills: Some(Vec::new()),
1155            ..Default::default()
1156        });
1157        // Overwritten by the patch:
1158        assert_eq!(patched.model.as_deref(), Some("new-model"));
1159        assert_eq!(patched.permission_mode, Some(PermissionMode::Yolo));
1160        assert_eq!(patched.enable_auto_memory, Some(false));
1161        assert_eq!(patched.short_prompt, Some(false));
1162        assert_eq!(patched.include_skills, Some(Vec::new()));
1163        // Untouched (patch unset == keep):
1164        assert_eq!(patched.provider.as_deref(), Some("anthropic"));
1165        assert_eq!(patched.system_prompt.as_deref(), Some("base prompt"));
1166        assert_eq!(patched.effort, Some(Effort::Medium));
1167        assert_eq!(patched.save_session, Some(true));
1168        assert_eq!(patched.unattended, Some(true));
1169        assert_eq!(patched.title.as_deref(), Some("pinned title"));
1170        assert_eq!(patched.exclude_skills, Some(vec!["noisy".to_string()]));
1171    }
1172
1173    #[test]
1174    fn unattended_is_optional_on_the_wire() {
1175        // A request without the field, and a resume op written before the
1176        // field existed, both read as attended.
1177        let request: SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
1178        assert_eq!(request.unattended, None);
1179
1180        let op = Op::ResumeSession { session_id: Id::new("ses"), unattended: true };
1181        let mut json = serde_json::to_value(&op).unwrap();
1182        json["ResumeSession"].as_object_mut().unwrap().remove("unattended");
1183        let decoded: Op = serde_json::from_value(json).unwrap();
1184        assert!(matches!(decoded, Op::ResumeSession { unattended: false, .. }));
1185    }
1186
1187    #[test]
1188    fn session_initialized_title_is_optional_on_the_wire() {
1189        let mut payload = SessionInfo {
1190            model: model_spec("m"),
1191            provider: provider_spec("p"),
1192            session_id: Id::new("ses"),
1193            cwd: PathBuf::from("/tmp"),
1194            permission_mode: PermissionMode::default(),
1195            skills: vec![],
1196            subagents: vec![],
1197            title: None,
1198        };
1199
1200        let json = serde_json::to_value(&payload).unwrap();
1201        assert!(json.get("title").is_none(), "an unset title is omitted: {json}");
1202        let decoded: SessionInfo = serde_json::from_value(json).unwrap();
1203        assert_eq!(decoded.title, None);
1204
1205        payload.title = Some("fix".to_string());
1206        let json = serde_json::to_value(&payload).unwrap();
1207        assert_eq!(json["title"], "fix");
1208    }
1209
1210    #[test]
1211    fn weight_class_serializes_lowercase_and_is_omitted_when_none() {
1212        let mut spec = model_spec("m");
1213        spec.weight_class = Some(WeightClass::Heavy);
1214        let json = serde_json::to_value(&spec).unwrap();
1215        assert_eq!(json["weight_class"], "heavy");
1216
1217        // None is skipped, not emitted as null.
1218        let json_none = serde_json::to_value(model_spec("m")).unwrap();
1219        assert!(json_none.get("weight_class").is_none());
1220
1221        // Round-trips from the lowercase wire form.
1222        let parsed: ModelSpec =
1223            serde_json::from_value(serde_json::json!({"id": "m", "weight_class": "feather"}))
1224                .unwrap();
1225        assert_eq!(parsed.weight_class, Some(WeightClass::Feather));
1226    }
1227
1228    #[test]
1229    fn weight_class_deserializes_case_insensitively() {
1230        for (value, expected) in [
1231            ("Feather", WeightClass::Feather),
1232            ("MIDDLE", WeightClass::Middle),
1233            ("hEaVy", WeightClass::Heavy),
1234        ] {
1235            let parsed: ModelSpec =
1236                serde_json::from_value(serde_json::json!({"id": "m", "weight_class": value}))
1237                    .unwrap();
1238            assert_eq!(parsed.weight_class, Some(expected));
1239        }
1240    }
1241
1242    #[test]
1243    fn weight_class_orders_feather_lightest_to_heavy_heaviest() {
1244        assert!(WeightClass::Feather < WeightClass::Middle);
1245        assert!(WeightClass::Middle < WeightClass::Heavy);
1246    }
1247
1248    fn provider_spec(name: &str) -> ProviderSpec {
1249        ProviderSpec {
1250            id: name.to_string(),
1251            display_name: name.to_string(),
1252            base_url: format!("https://api.{name}.test/v1"),
1253        }
1254    }
1255
1256    #[test]
1257    fn compact_events_serde_roundtrip() {
1258        let compact_start =
1259            serde_json::to_string(&Evt::CompactStart).expect("serialize CompactStart");
1260        let compact_end =
1261            serde_json::to_string(&Evt::CompactEnd { summary: Some("the summary".to_string()) })
1262                .expect("serialize CompactEnd");
1263
1264        assert_eq!(compact_start, "\"CompactStart\"");
1265        assert_eq!(compact_end, r#"{"CompactEnd":{"summary":"the summary"}}"#);
1266
1267        assert!(matches!(
1268            serde_json::from_str::<Evt>(&compact_start).expect("deserialize CompactStart"),
1269            Evt::CompactStart
1270        ));
1271        assert!(matches!(
1272            serde_json::from_str::<Evt>(&compact_end).expect("deserialize CompactEnd"),
1273            Evt::CompactEnd { summary: Some(s) } if s == "the summary"
1274        ));
1275        assert!(matches!(
1276            serde_json::from_str::<Evt>(r#"{"CompactEnd":{}}"#)
1277                .expect("deserialize CompactEnd without summary"),
1278            Evt::CompactEnd { summary: None }
1279        ));
1280    }
1281
1282    #[test]
1283    fn compact_op_serde_roundtrip() {
1284        let plain = serde_json::to_string(&Op::Compact { instructions: None })
1285            .expect("serialize bare Compact");
1286        assert_eq!(plain, r#"{"Compact":{}}"#);
1287        assert!(matches!(
1288            serde_json::from_str::<Op>(&plain).expect("deserialize bare Compact"),
1289            Op::Compact { instructions: None }
1290        ));
1291
1292        let steered =
1293            serde_json::to_string(&Op::Compact { instructions: Some("keep dates".to_string()) })
1294                .expect("serialize steered Compact");
1295        assert_eq!(steered, r#"{"Compact":{"instructions":"keep dates"}}"#);
1296        assert!(matches!(
1297            serde_json::from_str::<Op>(&steered).expect("deserialize steered Compact"),
1298            Op::Compact { instructions: Some(text) } if text == "keep dates"
1299        ));
1300    }
1301
1302    #[test]
1303    fn session_end_and_turn_resume_serde_roundtrip() {
1304        let session_id = Id::new("ses");
1305        let end = Evt::SessionEnd {
1306            session_id,
1307            reason: super::SessionEndReason::Shutdown,
1308            usage: Usage::new(10, 5),
1309        };
1310        let json = serde_json::to_string(&end).expect("serialize SessionEnd");
1311        let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize SessionEnd");
1312        assert!(matches!(
1313            decoded,
1314            Evt::SessionEnd { session_id: id, reason: super::SessionEndReason::Shutdown, usage }
1315                if id == session_id && usage.total() == 15
1316        ));
1317
1318        let turn_id = Id::new("op");
1319        let resume = Evt::TurnResume { turn_id };
1320        let json = serde_json::to_string(&resume).expect("serialize TurnResume");
1321        let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize TurnResume");
1322        assert!(matches!(decoded, Evt::TurnResume { turn_id: id } if id == turn_id));
1323    }
1324
1325    #[test]
1326    fn extension_refreshed_serde_roundtrip() {
1327        let event = Evt::ExtensionRefreshed(Box::new(ExtensionRefreshed {
1328            session_id: Id::new("ses"),
1329            skills: Vec::new(),
1330            subagents: Vec::new(),
1331            mcp_servers: Vec::new(),
1332        }));
1333
1334        let json = serde_json::to_string(&event).expect("serialize ExtensionRefreshed");
1335        let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize ExtensionRefreshed");
1336
1337        assert!(matches!(
1338            decoded,
1339            Evt::ExtensionRefreshed(payload)
1340                if payload.skills.is_empty() && payload.subagents.is_empty()
1341        ));
1342    }
1343
1344    #[test]
1345    fn session_update_op_serde_roundtrip() {
1346        let op = Op::UpdateSession(SessionUpdate {
1347            provider: Some("openai".to_string()),
1348            model: Some(ModelSpec {
1349                temperature: Some(0.2),
1350                effort: Some(super::Effort::High),
1351                ..model_spec("gpt-5.4")
1352            }),
1353            permission_mode: Some(PermissionMode::Yolo),
1354            title: Some("renamed".to_string()),
1355        });
1356
1357        let json = serde_json::to_string(&op).expect("serialize UpdateSession");
1358        let decoded = serde_json::from_str::<Op>(&json).expect("deserialize UpdateSession");
1359
1360        assert!(matches!(
1361            decoded,
1362            Op::UpdateSession(SessionUpdate {
1363                provider: Some(provider),
1364                model: Some(model),
1365                permission_mode: Some(PermissionMode::Yolo),
1366                title: Some(title),
1367            })
1368                if provider == "openai"
1369                    && model.id == "gpt-5.4"
1370                    && model.temperature == Some(0.2)
1371                    && model.effort == Some(super::Effort::High)
1372                    && title == "renamed"
1373        ));
1374    }
1375
1376    #[test]
1377    fn approval_response_uses_named_tool_decisions() {
1378        let turn_id = Id::new("turn");
1379        let op = Op::ApprovalResponse {
1380            turn_id,
1381            responses: vec![ToolDecision {
1382                tool_use_id: "call-1".to_string(),
1383                decision: ReviewDecision::Deny,
1384                message: Some("use the read-only endpoint".to_string()),
1385            }],
1386        };
1387
1388        let json = serde_json::to_value(&op).expect("serialize ApprovalResponse");
1389        assert_eq!(
1390            json["ApprovalResponse"]["responses"],
1391            serde_json::json!([{
1392                "tool_use_id": "call-1",
1393                "decision": "Deny",
1394                "message": "use the read-only endpoint"
1395            }])
1396        );
1397
1398        let decoded = serde_json::from_value::<Op>(json).expect("deserialize ApprovalResponse");
1399        assert!(matches!(
1400            decoded,
1401            Op::ApprovalResponse { turn_id: id, responses }
1402                if id == turn_id
1403                    && responses == vec![ToolDecision {
1404                        tool_use_id: "call-1".to_string(),
1405                        decision: ReviewDecision::Deny,
1406                        message: Some("use the read-only endpoint".to_string()),
1407                    }]
1408        ));
1409    }
1410
1411    #[test]
1412    fn session_updated_event_serde_roundtrip() {
1413        let session_id = Id::new("ses");
1414        let event = Evt::SessionUpdated(Box::new(SessionInfo {
1415            model: model_spec("claude-sonnet-4-6"),
1416            provider: provider_spec("anthropic"),
1417            session_id,
1418            cwd: PathBuf::from("/tmp/session-updated"),
1419            permission_mode: PermissionMode::default(),
1420            skills: vec![],
1421            subagents: vec![],
1422            title: None,
1423        }));
1424
1425        let json = serde_json::to_string(&event).expect("serialize SessionUpdated");
1426        let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize SessionUpdated");
1427
1428        assert!(matches!(
1429            decoded,
1430            Evt::SessionUpdated(payload)
1431                if payload.model.id == "claude-sonnet-4-6"
1432                    && payload.provider.id == "anthropic"
1433                    && payload.provider.base_url == "https://api.anthropic.test/v1"
1434                    && payload.session_id == session_id
1435                    && payload.cwd == std::path::Path::new("/tmp/session-updated")
1436        ));
1437    }
1438
1439    #[test]
1440    fn provider_spec_ignores_the_dropped_model_list() {
1441        // Payloads from daemons that still send the provider's model list
1442        // decode against the narrowed shape.
1443        let spec: ProviderSpec = serde_json::from_value(serde_json::json!({
1444            "id": "anthropic",
1445            "display_name": "Anthropic",
1446            "base_url": "https://api.anthropic.test/v1",
1447            "preferred_models": [{ "id": "claude-sonnet-4-6" }],
1448        }))
1449        .unwrap();
1450
1451        assert_eq!(spec.id, "anthropic");
1452        assert_eq!(spec.display_name, "Anthropic");
1453        assert_eq!(spec.base_url, "https://api.anthropic.test/v1");
1454
1455        // And the catalog data does not go back out.
1456        let encoded = serde_json::to_value(&spec).unwrap();
1457        assert!(encoded.get("preferred_models").is_none());
1458    }
1459
1460    #[test]
1461    fn context_report_serde_roundtrip() {
1462        let breakdown = super::ContextBreakdown {
1463            system_prompt_tokens: 1200,
1464            system_tools_tokens: 3400,
1465            mcp_tools_tokens: 0,
1466            memory_tokens: 800,
1467            skills_tokens: 150,
1468            messages_tokens: 42_000,
1469            used_tokens: 47_550,
1470            limit_tokens: Some(200_000),
1471            compact_buffer_tokens: 20_000,
1472        };
1473        let json = serde_json::to_value(Evt::ContextReport(breakdown)).expect("serialize");
1474        assert_eq!(
1475            json,
1476            serde_json::json!({
1477                "ContextReport": {
1478                    "system_prompt_tokens": 1200,
1479                    "system_tools_tokens": 3400,
1480                    "mcp_tools_tokens": 0,
1481                    "memory_tokens": 800,
1482                    "skills_tokens": 150,
1483                    "messages_tokens": 42000,
1484                    "used_tokens": 47550,
1485                    "limit_tokens": 200000,
1486                    "compact_buffer_tokens": 20000
1487                }
1488            })
1489        );
1490        let decoded = serde_json::from_value::<Evt>(json).expect("deserialize");
1491        assert!(matches!(decoded, Evt::ContextReport(b) if b == breakdown));
1492
1493        // Fields absent on the wire (older daemons) fall back to defaults.
1494        let sparse: super::ContextBreakdown =
1495            serde_json::from_value(serde_json::json!({"used_tokens": 10})).unwrap();
1496        assert_eq!(sparse.used_tokens, 10);
1497        assert_eq!(sparse.limit_tokens, None);
1498
1499        let op = serde_json::to_value(Op::ContextReport).expect("serialize op");
1500        assert_eq!(op, serde_json::json!("ContextReport"));
1501        assert!(matches!(serde_json::from_value::<Op>(op).unwrap(), Op::ContextReport));
1502    }
1503
1504    #[test]
1505    fn question_pause_serde_roundtrip() {
1506        let turn_id = Id::new("op");
1507        let pause = Evt::TurnPause {
1508            turn_id,
1509            reason: super::TurnPauseReason::Question {
1510                tool_use_id: "toolu_1".to_string(),
1511                questions: vec![super::QuestionSpec {
1512                    header: "Auth method".to_string(),
1513                    question: "Which auth method should we use?".to_string(),
1514                    multi_select: false,
1515                    options: vec![
1516                        super::QuestionOption {
1517                            label: "JWT (Recommended)".to_string(),
1518                            description: "Stateless tokens".to_string(),
1519                            preview: None,
1520                        },
1521                        super::QuestionOption {
1522                            label: "Sessions".to_string(),
1523                            description: "Server-side sessions".to_string(),
1524                            preview: Some("fn login() {}".to_string()),
1525                        },
1526                    ],
1527                }],
1528            },
1529        };
1530
1531        let json = serde_json::to_value(&pause).expect("serialize question pause");
1532        // `multi_select` defaults and `preview: None` is skipped, not null.
1533        let spec = &json["TurnPause"]["reason"]["Question"]["questions"][0];
1534        assert!(spec["options"][0].get("preview").is_none());
1535        assert_eq!(spec["options"][1]["preview"], "fn login() {}");
1536
1537        let decoded: Evt = serde_json::from_value(json).expect("deserialize question pause");
1538        let Evt::TurnPause {
1539            reason: super::TurnPauseReason::Question { tool_use_id, questions },
1540            ..
1541        } = decoded
1542        else {
1543            panic!("expected Question pause");
1544        };
1545        assert_eq!(tool_use_id, "toolu_1");
1546        assert_eq!(questions.len(), 1);
1547        assert!(!questions[0].multi_select);
1548
1549        // A spec without `multi_select` on the wire still decodes.
1550        let sparse: super::QuestionSpec = serde_json::from_value(serde_json::json!({
1551            "header": "Scope",
1552            "question": "How broad?",
1553            "options": [],
1554        }))
1555        .unwrap();
1556        assert!(!sparse.multi_select);
1557    }
1558
1559    #[test]
1560    fn question_response_serde_roundtrip() {
1561        let turn_id = Id::new("op");
1562        let op = Op::QuestionResponse {
1563            turn_id,
1564            tool_use_id: "toolu_1".to_string(),
1565            reply: super::QuestionReply::Answered(vec![super::QuestionAnswer {
1566                selected: vec!["JWT".to_string(), "Sessions".to_string()],
1567                note: None,
1568            }]),
1569        };
1570        let json = serde_json::to_string(&op).expect("serialize QuestionResponse");
1571        let decoded: Op = serde_json::from_str(&json).expect("deserialize QuestionResponse");
1572        assert!(matches!(
1573            decoded,
1574            Op::QuestionResponse { tool_use_id, reply: super::QuestionReply::Answered(answers), .. }
1575                if tool_use_id == "toolu_1" && answers[0].selected.len() == 2
1576        ));
1577
1578        // A skip is the bare variant; a discussion request without words is
1579        // an empty object, so `message` is never null on the wire.
1580        for (reply, expected) in [
1581            (super::QuestionReply::Dismissed, serde_json::json!("Dismissed")),
1582            (super::QuestionReply::Discuss { message: None }, serde_json::json!({ "Discuss": {} })),
1583            (
1584                super::QuestionReply::Discuss { message: Some("later".to_string()) },
1585                serde_json::json!({ "Discuss": { "message": "later" } }),
1586            ),
1587        ] {
1588            let json = serde_json::to_value(&reply).expect("serialize reply");
1589            assert_eq!(json, expected);
1590            let decoded: super::QuestionReply =
1591                serde_json::from_value(json).expect("deserialize reply");
1592            assert_eq!(decoded, reply);
1593        }
1594    }
1595
1596    #[test]
1597    fn usage_adds_cache_fields_without_overflowing() {
1598        let mut usage = Usage {
1599            input_tokens: 10,
1600            output_tokens: 20,
1601            cache_read_tokens: Some(3),
1602            cache_creation_tokens: None,
1603        };
1604        usage += Usage {
1605            input_tokens: 5,
1606            output_tokens: 6,
1607            cache_read_tokens: Some(4),
1608            cache_creation_tokens: Some(8),
1609        };
1610
1611        assert_eq!(usage.input_tokens, 15);
1612        assert_eq!(usage.output_tokens, 26);
1613        assert_eq!(usage.total(), 41);
1614        assert_eq!(usage.cache_read_tokens, Some(7));
1615        assert_eq!(usage.cache_creation_tokens, Some(8));
1616    }
1617}