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