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