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