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