Skip to main content

agent_engine/session/
wire.rs

1//! Wire frames for the daemon UDS (PLAN-phase2 §2.9, S289 D-2 amended).
2//!
3//! Framing is `synaps rpc`'s: one JSON object per `\n`, at most
4//! [`DAEMON_MAX_FRAME_BYTES`] (64 MiB — distinct from rpc's 1 MiB because
5//! `Attached` carries the whole `api_messages`). Enforced symmetrically:
6//! `encode_line` refuses to build an oversize frame, `decode_line` and both
7//! readers refuse to accept one. `WireSessionEvent` is a serde mirror of
8//! `SessionEventWire` — `StreamEvent`/`TurnError`/`ExtensionLoaderEvent` are
9//! not `Serialize`, so they are mirrored variant-for-variant here and
10//! converted only at the socket boundary. In-process transports never touch
11//! this module.
12//!
13//! The one *lossy* mirror is `Conversation`: on the wire it is a
14//! [`ConversationDigest`] (len + hash + tokens/cost), never the messages.
15//! Full history travels only in `Attached` and `QueryResult{Messages}`;
16//! `SocketTransport` rebuilds the snapshot from its local mirror
17//! (`Attached` + `Stream(MessageHistory)`) and re-queries on a hash miss.
18//!
19//! Security: nothing in here carries a credential. `Welcome`/`Attached` are
20//! summaries; `ClientFrame`'s `Debug` redacts `Answer.value` and
21//! `Submit.text` so a frame can be traced by *type* without leaking bodies.
22
23use std::path::PathBuf;
24
25use serde::{Deserialize, Serialize};
26
27use super::types::*;
28use super::view::RuntimeView;
29use crate::{AgentEvent, LlmEvent, SessionEvent, StreamEvent, TurnError, TurnOutcome};
30
31/// Separate from `RPC_PROTOCOL_VERSION`; bump on any non-additive change.
32/// v2 (phase 3): `Set` became a struct variant, `Bye` gained a reason.
33/// v3 (E-P0): driver events (`DriverArmed`, `DriverRevoked`,
34/// `DriverTurnOutcome`); old clients skip unknown via `#[serde(other)]`.
35/// Protocol stays exact-match; only *binary* versions compare directionally
36/// (reload, §2.8).
37/// `Close`/`Closed` were added without a bump: an older daemon answers the
38/// unknown `ClientFrame` with `Error{"malformed frame: unknown variant
39/// `close`…"}` (the client maps that to "daemon too old"), whereas a bump
40/// would refuse every older TUI that reconnects after a `daemon reload`.
41/// Additive `ClientFrame` variants that only a new client sends follow this
42/// rule; `Closed` only ever answers `Close`, so old clients never see it.
43/// `Pin`/`Pinned` (`synaps daemon pin`) follow the same rule, and so does
44/// `SessionMeta.pinned` (`#[serde(default)]`: an older daemon omits it).
45/// `ReloadExtension` and its two replies (`ExtensionReloaded`,
46/// `ExtensionReloadFailed`) follow it too.
47pub const PROTOCOL_VERSION: u32 = 3;
48/// Exact-match policy today (`min == max == PROTOCOL_VERSION`).
49pub const PROTOCOL_MIN: u32 = PROTOCOL_VERSION;
50pub const PROTOCOL_MAX: u32 = PROTOCOL_VERSION;
51/// rpc's cap, kept for reference; daemon frames use [`DAEMON_MAX_FRAME_BYTES`].
52pub const RPC_MAX_FRAME_BYTES: usize = agent_core::core::rpc_dispatch::MAX_FRAME_BYTES;
53/// Hard cap on one daemon frame, both directions (`Attached` ships the
54/// whole conversation; 64 MiB is well past any context window's JSON).
55pub const DAEMON_MAX_FRAME_BYTES: usize = 64 * 1024 * 1024;
56/// Alias used by the readers/writers of this protocol.
57pub const MAX_FRAME_BYTES: usize = DAEMON_MAX_FRAME_BYTES;
58/// Query ids at or above this are reserved for the transport/daemon and
59/// never surfaced to a client as `QueryResult`.
60pub const RESERVED_QUERY_ID_BASE: u64 = 1 << 63;
61/// `SocketTransport` re-fetches `api_messages` under this id on a digest miss.
62pub const DIGEST_RESYNC_QUERY_ID: u64 = RESERVED_QUERY_ID_BASE;
63/// The daemon's idle monitor probes `Status` under this id.
64pub const IDLE_PROBE_QUERY_ID: u64 = RESERVED_QUERY_ID_BASE + 1;
65/// `Checkpoint` replies `QueryResult{id: CHECKPOINT_QUERY_ID, {ok:true}}`
66/// so `reload.rs` can await it per session.
67pub const CHECKPOINT_QUERY_ID: u64 = RESERVED_QUERY_ID_BASE + 2;
68/// `SettingChanged{id}` broadcast when an engine command (`/model`,
69/// `/thinking`, …) changed the view: not a reply to any client's `Set`
70/// (client ids never reach the reserved range), only the new view, so every
71/// attached client mirrors it, not just the one that typed the command.
72pub const VIEW_CHANGED_SETTING_ID: u64 = RESERVED_QUERY_ID_BASE + 3;
73/// `synaps daemon close` sends the engine command `close` under a fresh id
74/// at or above this one per request (`daemon/close.rs`) and awaits that id's
75/// `QueryResult`, so concurrent closes never take each other's reply.
76/// Attached clients never see these.
77pub const CLOSE_QUERY_ID_BASE: u64 = RESERVED_QUERY_ID_BASE + (1 << 32);
78
79/// The binary version the daemon/client report in the handshake.
80pub fn binary_version() -> String {
81    env!("CARGO_PKG_VERSION").to_string()
82}
83
84static BUILD_ID: std::sync::OnceLock<String> = std::sync::OnceLock::new();
85
86/// Record this process's build id. The `synaps` binary calls it first thing
87/// in `main` with the id its build script baked in (the short commit,
88/// `-dirty` with uncommitted changes, or `SYNAPS_BUILD_ID` from the build
89/// environment). It lives in the root crate, not here, so a new commit does
90/// not recompile the engine. First call wins; later calls are ignored.
91pub fn set_build_id(id: &str) {
92    let _ = BUILD_ID.set(id.trim().to_string());
93}
94
95/// Which build of [`binary_version`] this is, as set by [`set_build_id`];
96/// empty when unknown (never set: library embedders, tests, packaged builds
97/// without git). Two builds of one release share the version, not this.
98pub fn build_id() -> &'static str {
99    BUILD_ID.get().map(String::as_str).unwrap_or("")
100}
101
102// ── client → daemon ───────────────────────────────────────────────────────────
103
104/// Client → daemon. First frame on a connection MUST be `Hello`.
105#[derive(Serialize, Deserialize)]
106#[serde(tag = "type", rename_all = "snake_case")]
107pub enum ClientFrame {
108    Hello(Hello),
109    // ── lightweight control fast path: no session allocated, no Attach ──
110    Ping,
111    Sessions,
112    Shutdown {
113        #[serde(default)]
114        force: bool,
115    },
116    // ── session-scoped ──
117    Attach(Attach),
118    Cmd {
119        session_id: SessionId,
120        cmd: SessionCommand,
121    },
122    /// Control fast path (no session): checkpoint every session, write
123    /// reload-state, execv self. Reply: `Reloading` to every attached
124    /// client, `Bye` to this one. (C3)
125    Reload {
126        #[serde(default)]
127        now: bool,
128        #[serde(default)]
129        drain_secs: Option<u64>,
130        #[serde(default)]
131        exe: Option<PathBuf>,
132    },
133    /// Control fast path: `memstat::purge_arenas()` in the daemon (bench
134    /// hygiene). Reply: `Pong`. (C2)
135    Purge,
136    /// Control fast path: end one session as `/close [force]` would, with
137    /// host authority (no attach, never unparks). A session with clients
138    /// attached is refused unless `attached` (`--attached`); `force` only
139    /// overrides work in flight. Reply: `Closed`, or
140    /// `Refused{Busy|UnknownSession}`. Not accepted on an attached
141    /// connection (that client has `/close`).
142    Close {
143        session_id: SessionId,
144        #[serde(default)]
145        force: bool,
146        #[serde(default)]
147        attached: bool,
148    },
149    /// Control fast path: pin (`on`) or unpin one session, as `/pin on|off`
150    /// would, with host authority. `on` is required so a frame always says
151    /// which way it goes. Pinning a parked session unparks it; unpinning one
152    /// leaves it parked. Reply: `Pinned`, or `Refused{Busy|UnknownSession}`.
153    /// Not accepted on an attached connection (that client has `/pin`).
154    Pin {
155        session_id: SessionId,
156        on: bool,
157    },
158    /// Control fast path (no session): the in-memory account state of the
159    /// broker every session of this daemon shares (`synaps auth state`),
160    /// cleared first when `clear` is set. Reply: `AuthState`. A daemon that
161    /// predates this frame answers `Error` "malformed frame: unknown variant
162    /// `auth_state`, ..." and keeps the connection usable.
163    AuthState(agent_core::auth::AuthStateQuery),
164    /// Control fast path (no session): stop one extension and start it
165    /// again from what is installed now (`synaps daemon reload-extension`).
166    /// Extensions are daemon-wide, so every session gets the new version;
167    /// no session is checkpointed and no turn is cancelled (only a call
168    /// into the old process that is in flight fails). Also loads a plugin
169    /// installed after the daemon started. Reply: `ExtensionReloaded`,
170    /// `ExtensionReloadFailed`, or `Refused{Busy}` while the daemon
171    /// reloads. Not accepted on an attached connection.
172    ReloadExtension {
173        extension: String,
174    },
175    Bye,
176}
177
178impl std::fmt::Debug for ClientFrame {
179    /// Frame *types* only for anything that can carry user text or a secret
180    /// (`SessionCommand`'s own `Debug` redacts bodies without lengths).
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        match self {
183            Self::Hello(h) => f.debug_tuple("Hello").field(h).finish(),
184            Self::Ping => f.write_str("Ping"),
185            Self::Sessions => f.write_str("Sessions"),
186            Self::Shutdown { force } => f.debug_struct("Shutdown").field("force", force).finish(),
187            Self::Attach(a) => f.debug_tuple("Attach").field(a).finish(),
188            Self::Cmd { session_id, cmd } => {
189                f.debug_struct("Cmd").field("session_id", session_id).field("cmd", cmd).finish()
190            }
191            Self::Reload { now, drain_secs, exe } => f
192                .debug_struct("Reload")
193                .field("now", now)
194                .field("drain_secs", drain_secs)
195                .field("exe", exe)
196                .finish(),
197            Self::Purge => f.write_str("Purge"),
198            Self::Close { session_id, force, attached } => f
199                .debug_struct("Close")
200                .field("session_id", session_id)
201                .field("force", force)
202                .field("attached", attached)
203                .finish(),
204            Self::Pin { session_id, on } => f
205                .debug_struct("Pin")
206                .field("session_id", session_id)
207                .field("on", on)
208                .finish(),
209            Self::AuthState(query) => f.debug_tuple("AuthState").field(query).finish(),
210            Self::ReloadExtension { extension } => f
211                .debug_struct("ReloadExtension")
212                .field("extension", extension)
213                .finish(),
214            Self::Bye => f.write_str("Bye"),
215        }
216    }
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct Hello {
221    pub protocol_version: u32,
222    pub client: ClientMeta,
223    pub cwd: PathBuf,
224    pub client_version: String,
225    /// Client environment snapshot (secrets and `SYNAPS_CLIENT_*`/`SYNAPS_TUI_*`/
226    /// `SYNAPS_DAEMON_*`/`SYNAPS_MEM_TRACE*` stripped). `None` = in-process
227    /// (inherit process env). Legacy frames without this field decode as `None`.
228    #[serde(default)]
229    pub env: Option<super::types::SessionEnv>,
230    /// Names of env vars stripped as secrets at the client (T5). Names only,
231    /// never values. `serde(default)` → legacy frames decode as empty.
232    #[serde(default, skip_serializing_if = "Vec::is_empty")]
233    pub env_stripped: Vec<String>,
234    /// Set when re-connecting after `Reloading`/EOF (C3/A4).
235    #[serde(default)]
236    pub reconnect_of: Option<ClientReconnect>,
237}
238
239/// Who this connection used to be, so the daemon can restore ownership by
240/// reconnect order + `was_owner`.
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242pub struct ClientReconnect {
243    pub previous_client: ClientId,
244    pub session_id: SessionId,
245    pub was_owner: bool,
246    pub generation: u64,
247}
248
249impl Hello {
250    pub fn new(kind: ClientKind) -> Self {
251        let (env, env_stripped) = super::types::capture_client_env();
252        Self {
253            protocol_version: PROTOCOL_VERSION,
254            client: ClientMeta {
255                terminal: std::env::var("TERM").ok(),
256                ..ClientMeta::new(kind)
257            },
258            cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
259            client_version: binary_version(),
260            env: Some(env),
261            env_stripped,
262            reconnect_of: None,
263        }
264    }
265
266    /// Ask to be called `name` in the session ([`ClientMeta::name`]).
267    pub fn with_name(mut self, name: Option<String>) -> Self {
268        self.client.name = name;
269        self
270    }
271
272    /// Ask for `Full` (mirror) or `Digest` (digest + display tail) history.
273    pub fn with_history(mut self, history: HistoryMode) -> Self {
274        self.client.history = history;
275        self
276    }
277
278    /// Display items wanted in `Attached.display_tail` / `DisplayTail` queries.
279    pub fn with_tail_items(mut self, tail_items: usize) -> Self {
280        self.client.tail_items = tail_items;
281        self
282    }
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
286#[serde(tag = "attach", rename_all = "snake_case")]
287pub enum Attach {
288    Existing {
289        session_id: SessionId,
290        #[serde(default = "mirror")]
291        mode: AttachMode,
292    },
293    Create {
294        #[serde(default)]
295        config: SessionConfig,
296        #[serde(default = "mirror")]
297        mode: AttachMode,
298    },
299}
300
301fn mirror() -> AttachMode {
302    AttachMode::Mirror
303}
304
305// ── daemon → client ───────────────────────────────────────────────────────────
306
307#[derive(Debug, Serialize, Deserialize)]
308#[serde(tag = "type", rename_all = "snake_case")]
309#[allow(clippy::large_enum_variant)]
310pub enum DaemonFrame {
311    Welcome(Welcome),
312    /// Then the daemon closes.
313    Refused {
314        reason: RefuseReason,
315        message: String,
316    },
317    Pong {
318        pid: u32,
319        uptime_s: u64,
320        sessions: usize,
321    },
322    SessionList {
323        sessions: Vec<SessionMeta>,
324    },
325    Attached(AttachedWire),
326    Event(WireEnvelope),
327    Error {
328        session_id: Option<SessionId>,
329        message: String,
330    },
331    /// Reply to `Close`: the session ended; `text` is the actor's `/close`
332    /// reply (how to continue it).
333    Closed {
334        session_id: SessionId,
335        text: String,
336    },
337    /// Reply to `Pin`: the session's pin is now `on`.
338    Pinned {
339        session_id: SessionId,
340        on: bool,
341    },
342    /// Reply to `ReloadExtension`: the new version is loaded.
343    ExtensionReloaded {
344        report: crate::extensions::manager::ExtensionReloadReport,
345    },
346    /// Reply to `ReloadExtension`: it was not reloaded. `unloaded`: the old
347    /// instance was stopped before the new one failed to start, so the
348    /// extension is not loaded now; `false` = the running one (if any) was
349    /// not touched.
350    ExtensionReloadFailed {
351        extension: String,
352        message: String,
353        #[serde(default)]
354        unloaded: bool,
355    },
356    /// `Some(Reloading{..})` before exec; `None` otherwise (today).
357    Bye {
358        #[serde(default)]
359        reason: Option<ByeReason>,
360    },
361    /// Reply to `ClientFrame::AuthState`. Labels, kinds and times only:
362    /// never token material, emails, account ids or identity hashes.
363    AuthState {
364        report: agent_core::auth::AuthStateReport,
365    },
366}
367
368#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
369#[serde(tag = "bye", rename_all = "snake_case")]
370pub enum ByeReason {
371    Reloading { generation: u64, retry_after_ms: u64 },
372    Shutdown,
373    Detached,
374}
375
376#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
377#[serde(tag = "reason", rename_all = "snake_case")]
378pub enum RefuseReason {
379    Version {
380        daemon_version: u32,
381        min: u32,
382        max: u32,
383    },
384    Protocol,
385    Busy,
386    UnknownSession,
387    Config,
388    /// `synaps daemon reload` version gate failed (C3).
389    ReloadRefused { why: String },
390}
391
392/// Auth-status *summary* only — never key material.
393#[derive(Debug, Clone, Serialize, Deserialize)]
394pub struct Welcome {
395    pub protocol_version: u32,
396    pub daemon_version: String,
397    pub pid: u32,
398    pub profile: Option<String>,
399    pub sessions: Vec<SessionMeta>,
400    pub progressive_tool_disclosure: bool,
401    /// Reload counter (starts at 1).
402    #[serde(default = "one")]
403    pub generation: u64,
404    /// The daemon's [`build_id`]: tells two builds of one version apart.
405    /// Empty from older daemons (unknown). `daemon_version` stays the plain
406    /// semver the reload gate parses.
407    #[serde(default)]
408    pub daemon_build: String,
409}
410
411fn one() -> u64 {
412    1
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct WireEnvelope {
417    pub session_id: SessionId,
418    pub seq: u64,
419    pub ts: chrono::DateTime<chrono::Utc>,
420    pub event: WireSessionEvent,
421}
422
423impl From<Envelope> for WireEnvelope {
424    fn from(e: Envelope) -> Self {
425        Self {
426            session_id: e.session_id,
427            seq: e.seq,
428            ts: e.ts,
429            event: e.event.into(),
430        }
431    }
432}
433
434impl From<WireEnvelope> for Envelope {
435    fn from(e: WireEnvelope) -> Self {
436        Self {
437            session_id: e.session_id,
438            seq: e.seq,
439            ts: e.ts,
440            event: e.event.into(),
441        }
442    }
443}
444
445/// `AttachSnapshot` with a serialisable replay.
446#[derive(Debug, Clone, Serialize, Deserialize)]
447pub struct AttachedWire {
448    pub client: ClientId,
449    pub meta: SessionMeta,
450    pub view: RuntimeView,
451    pub conversation: ConversationSnapshot,
452    pub streaming: bool,
453    pub replay: Vec<WireEnvelope>,
454    pub pending_prompts: Vec<PromptRequest>,
455    pub clients: Vec<(ClientId, ClientKind)>,
456    /// Empty from an older daemon.
457    #[serde(default, skip_serializing_if = "Vec::is_empty")]
458    pub roster: Vec<super::types::ClientInfo>,
459    /// Absent from an older daemon, which enforces the owner: `Exclusive`.
460    #[serde(default = "ClientInput::wire_default")]
461    pub client_input: ClientInput,
462    #[serde(default)]
463    pub input_owner: Option<ClientId>,
464    #[serde(default)]
465    pub display_tail: Option<crate::session::display::DisplayTail>,
466}
467
468impl AttachedWire {
469    pub fn new(client: ClientId, s: AttachSnapshot) -> Self {
470        Self {
471            client,
472            meta: s.meta,
473            view: s.view,
474            conversation: s.conversation,
475            streaming: s.streaming,
476            replay: s.replay.into_iter().map(Into::into).collect(),
477            pending_prompts: s.pending_prompts,
478            clients: s.clients,
479            roster: s.roster,
480            client_input: s.client_input,
481            input_owner: s.input_owner,
482            display_tail: s.display_tail,
483        }
484    }
485
486    pub fn into_snapshot(self) -> (ClientId, AttachSnapshot) {
487        (
488            self.client,
489            AttachSnapshot {
490                meta: self.meta,
491                view: self.view,
492                conversation: self.conversation,
493                streaming: self.streaming,
494                replay: self.replay.into_iter().map(Into::into).collect(),
495                pending_prompts: self.pending_prompts,
496                clients: self.clients,
497                roster: self.roster,
498                client_input: self.client_input,
499                input_owner: self.input_owner,
500                display_tail: self.display_tail,
501            },
502        )
503    }
504}
505
506// ── conversation digest ───────────────────────────────────────────────────────
507
508/// `ConversationSnapshot` minus `api_messages`: what `Conversation` carries
509/// on the wire. `messages_hash` is FNV-1a over each message's JSON so a
510/// client can tell whether its local mirror is current.
511#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
512pub struct ConversationDigest {
513    #[serde(default)]
514    pub header: SessionHeader,
515    pub messages_len: usize,
516    pub messages_hash: u64,
517    pub tokens: ConversationTokens,
518    pub cost: f64,
519    #[serde(default)]
520    pub keepwarm_cost: f64,
521    #[serde(default)]
522    pub keepwarm_tokens: ConversationTokens,
523    pub abort_context: Option<String>,
524    pub queued_message: Option<String>,
525    pub pending_events_len: usize,
526    pub consecutive_auto_turns: u32,
527}
528
529/// FNV-1a 64 over the canonical JSON of every message (stable across
530/// binaries, unlike `DefaultHasher`).
531pub fn messages_hash(messages: &[crate::SharedMessage]) -> u64 {
532    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
533    let mut feed = |b: u8| {
534        h ^= b as u64;
535        h = h.wrapping_mul(0x0000_0100_0000_01b3);
536    };
537    for m in messages {
538        if let Ok(bytes) = serde_json::to_vec(&**m) {
539            for b in bytes {
540                feed(b);
541            }
542        }
543        feed(0x1f);
544    }
545    h
546}
547
548impl ConversationDigest {
549    pub fn of(s: &ConversationSnapshot) -> Self {
550        Self {
551            header: s.header.clone(),
552            messages_len: s.api_messages.len(),
553            messages_hash: s.messages_hash_memo.hash(&s.api_messages),
554            tokens: s.tokens.clone(),
555            cost: s.cost,
556            keepwarm_cost: s.keepwarm_cost,
557            keepwarm_tokens: s.keepwarm_tokens.clone(),
558            abort_context: s.abort_context.clone(),
559            queued_message: s.queued_message.clone(),
560            pending_events_len: s.pending_events_len,
561            consecutive_auto_turns: s.consecutive_auto_turns,
562        }
563    }
564
565    pub fn matches(&self, messages: &[crate::SharedMessage]) -> bool {
566        self.messages_len == messages.len() && self.messages_hash == messages_hash(messages)
567    }
568
569    /// Rebuild a snapshot around `messages` (the caller's mirror).
570    pub fn into_snapshot(self, api_messages: Vec<crate::SharedMessage>) -> ConversationSnapshot {
571        ConversationSnapshot {
572            header: self.header,
573            messages_len: self.messages_len,
574            api_messages,
575            tokens: self.tokens,
576            cost: self.cost,
577            keepwarm_cost: self.keepwarm_cost,
578            keepwarm_tokens: self.keepwarm_tokens,
579            abort_context: self.abort_context,
580            queued_message: self.queued_message,
581            pending_events_len: self.pending_events_len,
582            consecutive_auto_turns: self.consecutive_auto_turns,
583            messages_hash_memo: Default::default(),
584        }
585    }
586}
587
588// ── mirror of SessionEventWire (lossless except Conversation → digest) ────────
589
590#[derive(Debug, Clone, Serialize, Deserialize)]
591#[serde(tag = "ev", rename_all = "snake_case")]
592#[allow(clippy::large_enum_variant)] // mirrors SessionEventWire by design
593pub enum WireSessionEvent {
594    Stream { event: WireStreamEvent },
595    TurnStarted {
596        turn_baseline: usize,
597        trigger: TurnTrigger,
598        #[serde(default)]
599        user_text: Option<String>,
600    },
601    /// Digest only — see module docs.
602    Conversation { digest: ConversationDigest },
603    Prompt { request: PromptRequest },
604    PromptResolved { prompt_id: u64 },
605    External { event: crate::events::types::Event },
606    AutoTurnCapReached { cap: u32 },
607    Idle,
608    Steered { text: String, delivered: bool },
609    Dequeued { text: String },
610    SystemNotice { text: String },
611    LoaderProgress { event: WireLoaderEvent },
612    ExtensionNotification { extension_id: String, method: String, params: serde_json::Value },
613    SettingChanged { applied: SettingApplied },
614    QueryResult { id: u64, value: serde_json::Value },
615    Attached { client: ClientId, snapshot: AttachedWire },
616    ClientJoined {
617        client: ClientId,
618        kind: ClientKind,
619        /// Empty from an older daemon (the client falls back to the kind).
620        #[serde(default)]
621        name: String,
622        /// Absent from an older daemon (read as `Mirror`).
623        #[serde(default, skip_serializing_if = "Option::is_none")]
624        mode: Option<AttachMode>,
625    },
626    ClientLeft { client: ClientId },
627    Ended { reason: EndReason },
628    Aborted { context_saved: bool },
629    Cleared { session_id: String },
630    CompactionStarted { source: String, disclosure: String },
631    CompactionApplied {
632        previous_session_id: String,
633        session_id: String,
634        chains_advanced: Vec<String>,
635        queued_restored: Option<String>,
636        msg_count: usize,
637    },
638    CompactionFailed { message: String, panicked: bool },
639    CompactionCancelled,
640    SubagentRows { rows: Vec<crate::runtime::subagent::SubagentDisplayRow> },
641    Resumed {
642        id: u64,
643        old_id: String,
644        new_id: String,
645        via: Option<String>,
646        clamp_notice: Option<String>,
647    },
648    InputOwnerChanged { from: Option<ClientId>, to: Option<ClientId>, reason: OwnerChangeReason },
649    Refused { client: ClientId, command: String, reason: String },
650    AttachRefused { message: String },
651    Lifecycle { lifecycle: SessionLifecycle },
652    Reloading { generation: u64, retry_after_ms: u64 },
653    /// (E-P0) Driver armed.
654    DriverArmed {
655        plugin_id: String,
656        run_id: String,
657        models: Vec<crate::extensions::session_driver::Selection>,
658        selection: crate::extensions::session_driver::Selection,
659        #[serde(default)]
660        deadline_ms: Option<u64>,
661        #[serde(default)]
662        notice: String,
663    },
664    /// (E-P0) Driver revoked.
665    DriverRevoked {
666        reason: String,
667        #[serde(default)]
668        undelivered_steering: Vec<String>,
669    },
670    /// (E-P0) Driver turn outcome.
671    DriverTurnOutcome {
672        outcome: crate::extensions::session_driver::Outcome,
673        selection: crate::extensions::session_driver::Selection,
674        #[serde(default)]
675        feedback: Option<String>,
676    },
677    /// (E-P7) Spend ceiling breached; turn cancelled and driver revoked.
678    CostCapReached {
679        scope: String,
680        cost: f64,
681        cap: f64,
682    },
683    /// Forward-compat: an additive variant from a newer daemon.
684    #[serde(other)]
685    Unknown,
686}
687
688#[derive(Debug, Clone, Serialize, Deserialize)]
689#[serde(tag = "kind", rename_all = "snake_case")]
690pub enum WireStreamEvent {
691    Llm(WireLlmEvent),
692    Session(WireSessionStreamEvent),
693    Agent(WireAgentEvent),
694}
695
696#[derive(Debug, Clone, Serialize, Deserialize)]
697#[serde(tag = "llm", rename_all = "snake_case")]
698pub enum WireLlmEvent {
699    Thinking { text: String },
700    Text { text: String },
701    ToolUseStart { tool_name: String, tool_id: String },
702    ToolUseDelta { tool_id: String, delta: String },
703    ToolUse { tool_name: String, tool_id: String, input: serde_json::Value },
704    ToolResult { tool_id: String, result: String },
705    ToolResultDelta { tool_id: String, delta: String },
706    ResponseStart,
707    ResponseReset,
708    /// Opt-in (`ClientCapabilities::tool_queue`): this enum has no
709    /// `#[serde(other)]`, so an older client fails the WHOLE frame on an
710    /// unknown variant — the daemon never sends these two to a client that
711    /// did not declare it can decode them (`daemon/conn.rs`).
712    ToolQueued { tool_id: String },
713    ToolStarted { tool_id: String },
714}
715
716#[derive(Debug, Clone, Serialize, Deserialize)]
717#[serde(tag = "session", rename_all = "snake_case")]
718pub enum WireSessionStreamEvent {
719    MessageHistory { messages: Vec<crate::SharedMessage> },
720    Usage {
721        input_tokens: u64,
722        output_tokens: u64,
723        cache_read_input_tokens: u64,
724        cache_creation_input_tokens: u64,
725        cache_creation_5m: Option<u64>,
726        cache_creation_1h: Option<u64>,
727        model: Option<String>,
728    },
729    Done,
730    Error { message: String, outcome: TurnOutcome },
731    /// `retry`: a `SessionEvent::Retry`. Omitted when false, so a plain
732    /// notice's frame is unchanged and peers that predate the flag read a
733    /// retry as a plain notice.
734    Notice {
735        text: String,
736        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
737        retry: bool,
738    },
739}
740
741#[derive(Debug, Clone, Serialize, Deserialize)]
742#[serde(tag = "agent", rename_all = "snake_case")]
743pub enum WireAgentEvent {
744    SubagentStart { subagent_id: u64, agent_name: String, task_preview: String },
745    SubagentUpdate { subagent_id: u64, agent_name: String, status: String },
746    SubagentDone { subagent_id: u64, agent_name: String, result_preview: String, duration_secs: f64 },
747    SteeringDelivered { message: String },
748}
749
750#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
751pub struct WireLoadFailure {
752    pub plugin: String,
753    pub manifest_path: Option<PathBuf>,
754    pub reason: String,
755    pub hint: String,
756}
757
758#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
759#[serde(tag = "loader", rename_all = "snake_case")]
760pub enum WireLoaderEvent {
761    Started,
762    Loaded { plugin: String, loaded: usize, failed: usize },
763    Failed { failure: WireLoadFailure, loaded: usize, failed: usize },
764    Finished { loaded: Vec<String>, failed: Vec<WireLoadFailure> },
765}
766
767type LoadFailure = crate::extensions::manager::ExtensionLoadFailure;
768type LoaderEvent = crate::extensions::loader::ExtensionLoaderEvent;
769
770impl From<LoadFailure> for WireLoadFailure {
771    fn from(f: LoadFailure) -> Self {
772        Self { plugin: f.plugin, manifest_path: f.manifest_path, reason: f.reason, hint: f.hint }
773    }
774}
775impl From<WireLoadFailure> for LoadFailure {
776    fn from(f: WireLoadFailure) -> Self {
777        Self { plugin: f.plugin, manifest_path: f.manifest_path, reason: f.reason, hint: f.hint }
778    }
779}
780impl From<LoaderEvent> for WireLoaderEvent {
781    fn from(e: LoaderEvent) -> Self {
782        match e {
783            LoaderEvent::Started => Self::Started,
784            LoaderEvent::Loaded { plugin, loaded, failed } => Self::Loaded { plugin, loaded, failed },
785            LoaderEvent::Failed { failure, loaded, failed } => {
786                Self::Failed { failure: failure.into(), loaded, failed }
787            }
788            LoaderEvent::Finished { loaded, failed } => Self::Finished {
789                loaded,
790                failed: failed.into_iter().map(Into::into).collect(),
791            },
792        }
793    }
794}
795impl From<WireLoaderEvent> for LoaderEvent {
796    fn from(e: WireLoaderEvent) -> Self {
797        match e {
798            WireLoaderEvent::Started => Self::Started,
799            WireLoaderEvent::Loaded { plugin, loaded, failed } => Self::Loaded { plugin, loaded, failed },
800            WireLoaderEvent::Failed { failure, loaded, failed } => {
801                Self::Failed { failure: failure.into(), loaded, failed }
802            }
803            WireLoaderEvent::Finished { loaded, failed } => Self::Finished {
804                loaded,
805                failed: failed.into_iter().map(Into::into).collect(),
806            },
807        }
808    }
809}
810
811impl From<StreamEvent> for WireStreamEvent {
812    fn from(e: StreamEvent) -> Self {
813        match e {
814            StreamEvent::Llm(l) => Self::Llm(match l {
815                LlmEvent::Thinking(text) => WireLlmEvent::Thinking { text },
816                LlmEvent::Text(text) => WireLlmEvent::Text { text },
817                LlmEvent::ToolUseStart { tool_name, tool_id } => WireLlmEvent::ToolUseStart { tool_name, tool_id },
818                LlmEvent::ToolUseDelta { tool_id, delta } => WireLlmEvent::ToolUseDelta { tool_id, delta },
819                LlmEvent::ToolUse { tool_name, tool_id, input } => WireLlmEvent::ToolUse { tool_name, tool_id, input },
820                LlmEvent::ToolResult { tool_id, result } => WireLlmEvent::ToolResult { tool_id, result },
821                LlmEvent::ToolResultDelta { tool_id, delta } => WireLlmEvent::ToolResultDelta { tool_id, delta },
822                LlmEvent::ResponseStart => WireLlmEvent::ResponseStart,
823                LlmEvent::ResponseReset => WireLlmEvent::ResponseReset,
824                LlmEvent::ToolQueued { tool_id } => WireLlmEvent::ToolQueued { tool_id },
825                LlmEvent::ToolStarted { tool_id } => WireLlmEvent::ToolStarted { tool_id },
826            }),
827            StreamEvent::Session(s) => Self::Session(match s {
828                SessionEvent::MessageHistory(messages) => WireSessionStreamEvent::MessageHistory { messages },
829                SessionEvent::Usage {
830                    input_tokens,
831                    output_tokens,
832                    cache_read_input_tokens,
833                    cache_creation_input_tokens,
834                    cache_creation_5m,
835                    cache_creation_1h,
836                    model,
837                } => WireSessionStreamEvent::Usage {
838                    input_tokens,
839                    output_tokens,
840                    cache_read_input_tokens,
841                    cache_creation_input_tokens,
842                    cache_creation_5m,
843                    cache_creation_1h,
844                    model,
845                },
846                SessionEvent::Done => WireSessionStreamEvent::Done,
847                SessionEvent::Error(TurnError { message, outcome }) => WireSessionStreamEvent::Error { message, outcome },
848                SessionEvent::Notice(text) => WireSessionStreamEvent::Notice { text, retry: false },
849                SessionEvent::Retry(text) => WireSessionStreamEvent::Notice { text, retry: true },
850                // merge(112): ContextHeadCheckpoint is actor-internal (Wall 1); never crosses the wire.
851                // Drop it silently — the actor handles persistence before forwarding.
852                SessionEvent::ContextHeadCheckpoint { .. } => WireSessionStreamEvent::Done,
853            }),
854            StreamEvent::Agent(a) => Self::Agent(match a {
855                AgentEvent::SubagentStart { subagent_id, agent_name, task_preview } => {
856                    WireAgentEvent::SubagentStart { subagent_id, agent_name, task_preview }
857                }
858                AgentEvent::SubagentUpdate { subagent_id, agent_name, status } => {
859                    WireAgentEvent::SubagentUpdate { subagent_id, agent_name, status }
860                }
861                AgentEvent::SubagentDone { subagent_id, agent_name, result_preview, duration_secs } => {
862                    WireAgentEvent::SubagentDone { subagent_id, agent_name, result_preview, duration_secs }
863                }
864                AgentEvent::SteeringDelivered { message } => WireAgentEvent::SteeringDelivered { message },
865            }),
866        }
867    }
868}
869
870impl From<WireStreamEvent> for StreamEvent {
871    fn from(e: WireStreamEvent) -> Self {
872        match e {
873            WireStreamEvent::Llm(l) => Self::Llm(match l {
874                WireLlmEvent::Thinking { text } => LlmEvent::Thinking(text),
875                WireLlmEvent::Text { text } => LlmEvent::Text(text),
876                WireLlmEvent::ToolUseStart { tool_name, tool_id } => LlmEvent::ToolUseStart { tool_name, tool_id },
877                WireLlmEvent::ToolUseDelta { tool_id, delta } => LlmEvent::ToolUseDelta { tool_id, delta },
878                WireLlmEvent::ToolUse { tool_name, tool_id, input } => LlmEvent::ToolUse { tool_name, tool_id, input },
879                WireLlmEvent::ToolResult { tool_id, result } => LlmEvent::ToolResult { tool_id, result },
880                WireLlmEvent::ToolResultDelta { tool_id, delta } => LlmEvent::ToolResultDelta { tool_id, delta },
881                WireLlmEvent::ResponseStart => LlmEvent::ResponseStart,
882                WireLlmEvent::ResponseReset => LlmEvent::ResponseReset,
883                WireLlmEvent::ToolQueued { tool_id } => LlmEvent::ToolQueued { tool_id },
884                WireLlmEvent::ToolStarted { tool_id } => LlmEvent::ToolStarted { tool_id },
885            }),
886            WireStreamEvent::Session(s) => Self::Session(match s {
887                WireSessionStreamEvent::MessageHistory { messages } => SessionEvent::MessageHistory(messages),
888                WireSessionStreamEvent::Usage {
889                    input_tokens,
890                    output_tokens,
891                    cache_read_input_tokens,
892                    cache_creation_input_tokens,
893                    cache_creation_5m,
894                    cache_creation_1h,
895                    model,
896                } => SessionEvent::Usage {
897                    input_tokens,
898                    output_tokens,
899                    cache_read_input_tokens,
900                    cache_creation_input_tokens,
901                    cache_creation_5m,
902                    cache_creation_1h,
903                    model,
904                },
905                WireSessionStreamEvent::Done => SessionEvent::Done,
906                WireSessionStreamEvent::Error { message, outcome } => SessionEvent::Error(TurnError { message, outcome }),
907                WireSessionStreamEvent::Notice { text, retry: false } => SessionEvent::Notice(text),
908                WireSessionStreamEvent::Notice { text, retry: true } => SessionEvent::Retry(text),
909            }),
910            WireStreamEvent::Agent(a) => Self::Agent(match a {
911                WireAgentEvent::SubagentStart { subagent_id, agent_name, task_preview } => {
912                    AgentEvent::SubagentStart { subagent_id, agent_name, task_preview }
913                }
914                WireAgentEvent::SubagentUpdate { subagent_id, agent_name, status } => {
915                    AgentEvent::SubagentUpdate { subagent_id, agent_name, status }
916                }
917                WireAgentEvent::SubagentDone { subagent_id, agent_name, result_preview, duration_secs } => {
918                    AgentEvent::SubagentDone { subagent_id, agent_name, result_preview, duration_secs }
919                }
920                WireAgentEvent::SteeringDelivered { message } => AgentEvent::SteeringDelivered { message },
921            }),
922        }
923    }
924}
925
926impl From<SessionEventWire> for WireSessionEvent {
927    fn from(e: SessionEventWire) -> Self {
928        use SessionEventWire as S;
929        match e {
930            S::Stream(ev) => Self::Stream { event: ev.into() },
931            S::TurnStarted { turn_baseline, trigger, user_text } => {
932                Self::TurnStarted { turn_baseline, trigger, user_text }
933            }
934            S::Conversation(snapshot) => Self::Conversation { digest: ConversationDigest::of(&snapshot) },
935            S::Prompt(request) => Self::Prompt { request },
936            S::PromptResolved { prompt_id } => Self::PromptResolved { prompt_id },
937            S::External(event) => Self::External { event },
938            S::AutoTurnCapReached { cap } => Self::AutoTurnCapReached { cap },
939            S::Idle => Self::Idle,
940            S::Steered { text, delivered } => Self::Steered { text, delivered },
941            S::Dequeued { text } => Self::Dequeued { text },
942            S::SystemNotice(text) => Self::SystemNotice { text },
943            S::LoaderProgress(ev) => Self::LoaderProgress { event: ev.into() },
944            S::ExtensionNotification { extension_id, method, params } => {
945                Self::ExtensionNotification { extension_id, method, params }
946            }
947            S::SettingChanged(applied) => Self::SettingChanged { applied },
948            S::QueryResult { id, value } => Self::QueryResult { id, value },
949            S::Attached { client, snapshot } => Self::Attached { client, snapshot: AttachedWire::new(client, snapshot) },
950            S::ClientJoined { client, kind, name, mode } => Self::ClientJoined { client, kind, name, mode: Some(mode) },
951            S::ClientLeft { client } => Self::ClientLeft { client },
952            S::Ended { reason } => Self::Ended { reason },
953            S::Aborted { context_saved } => Self::Aborted { context_saved },
954            S::Cleared { session_id } => Self::Cleared { session_id },
955            S::CompactionStarted { source, disclosure } => Self::CompactionStarted { source, disclosure },
956            S::CompactionApplied { previous_session_id, session_id, chains_advanced, queued_restored, msg_count } => {
957                Self::CompactionApplied { previous_session_id, session_id, chains_advanced, queued_restored, msg_count }
958            }
959            S::CompactionFailed { message, panicked } => Self::CompactionFailed { message, panicked },
960            S::CompactionCancelled => Self::CompactionCancelled,
961            S::SubagentRows(rows) => Self::SubagentRows { rows },
962            S::Resumed { id, old_id, new_id, via, clamp_notice } => {
963                Self::Resumed { id, old_id, new_id, via, clamp_notice }
964            }
965            S::InputOwnerChanged { from, to, reason } => Self::InputOwnerChanged { from, to, reason },
966            S::Refused { client, command, reason } => Self::Refused { client, command, reason },
967            S::AttachRefused { message } => Self::AttachRefused { message },
968            S::Lifecycle(lifecycle) => Self::Lifecycle { lifecycle },
969            S::Reloading { generation, retry_after_ms } => Self::Reloading { generation, retry_after_ms },
970            S::DriverArmed { plugin_id, run_id, models, selection, deadline_ms, notice } => {
971                Self::DriverArmed { plugin_id, run_id, models, selection, deadline_ms, notice }
972            }
973            S::DriverRevoked { reason, undelivered_steering } => {
974                Self::DriverRevoked { reason, undelivered_steering }
975            }
976            S::DriverTurnOutcome { outcome, selection, feedback } => {
977                Self::DriverTurnOutcome { outcome, selection, feedback }
978            }
979            S::CostCapReached { scope, cost, cap } => {
980                Self::CostCapReached { scope, cost, cap }
981            }
982        }
983    }
984}
985
986impl From<WireSessionEvent> for SessionEventWire {
987    fn from(e: WireSessionEvent) -> Self {
988        use WireSessionEvent as W;
989        match e {
990            W::Stream { event } => Self::Stream(event.into()),
991            W::TurnStarted { turn_baseline, trigger, user_text } => {
992                Self::TurnStarted { turn_baseline, trigger, user_text }
993            }
994            // Messages are not on the wire; `SocketTransport` fills them from
995            // its mirror before handing the envelope up.
996            W::Conversation { digest } => Self::Conversation(digest.into_snapshot(Vec::new())),
997            W::Prompt { request } => Self::Prompt(request),
998            W::PromptResolved { prompt_id } => Self::PromptResolved { prompt_id },
999            W::External { event } => Self::External(event),
1000            W::AutoTurnCapReached { cap } => Self::AutoTurnCapReached { cap },
1001            W::Idle => Self::Idle,
1002            W::Steered { text, delivered } => Self::Steered { text, delivered },
1003            W::Dequeued { text } => Self::Dequeued { text },
1004            W::SystemNotice { text } => Self::SystemNotice(text),
1005            W::LoaderProgress { event } => Self::LoaderProgress(event.into()),
1006            W::ExtensionNotification { extension_id, method, params } => {
1007                Self::ExtensionNotification { extension_id, method, params }
1008            }
1009            W::SettingChanged { applied } => Self::SettingChanged(applied),
1010            W::QueryResult { id, value } => Self::QueryResult { id, value },
1011            W::Attached { snapshot, .. } => {
1012                let (client, snapshot) = snapshot.into_snapshot();
1013                Self::Attached { client, snapshot }
1014            }
1015            W::ClientJoined { client, kind, name, mode } => {
1016                Self::ClientJoined { client, kind, name, mode: mode.unwrap_or(AttachMode::Mirror) }
1017            }
1018            W::ClientLeft { client } => Self::ClientLeft { client },
1019            W::Ended { reason } => Self::Ended { reason },
1020            W::Aborted { context_saved } => Self::Aborted { context_saved },
1021            W::Cleared { session_id } => Self::Cleared { session_id },
1022            W::CompactionStarted { source, disclosure } => Self::CompactionStarted { source, disclosure },
1023            W::CompactionApplied { previous_session_id, session_id, chains_advanced, queued_restored, msg_count } => {
1024                Self::CompactionApplied { previous_session_id, session_id, chains_advanced, queued_restored, msg_count }
1025            }
1026            W::CompactionFailed { message, panicked } => Self::CompactionFailed { message, panicked },
1027            W::CompactionCancelled => Self::CompactionCancelled,
1028            W::SubagentRows { rows } => Self::SubagentRows(rows),
1029            W::Resumed { id, old_id, new_id, via, clamp_notice } => {
1030                Self::Resumed { id, old_id, new_id, via, clamp_notice }
1031            }
1032            W::InputOwnerChanged { from, to, reason } => Self::InputOwnerChanged { from, to, reason },
1033            W::Refused { client, command, reason } => Self::Refused { client, command, reason },
1034            W::AttachRefused { message } => Self::AttachRefused { message },
1035            W::Lifecycle { lifecycle } => Self::Lifecycle(lifecycle),
1036            W::Reloading { generation, retry_after_ms } => Self::Reloading { generation, retry_after_ms },
1037            W::DriverArmed { plugin_id, run_id, models, selection, deadline_ms, notice } => {
1038                Self::DriverArmed { plugin_id, run_id, models, selection, deadline_ms, notice }
1039            }
1040            W::DriverRevoked { reason, undelivered_steering } => {
1041                Self::DriverRevoked { reason, undelivered_steering }
1042            }
1043            W::DriverTurnOutcome { outcome, selection, feedback } => {
1044                Self::DriverTurnOutcome { outcome, selection, feedback }
1045            }
1046            W::CostCapReached { scope, cost, cap } => {
1047                Self::CostCapReached { scope, cost, cap }
1048            }
1049            W::Unknown => Self::SystemNotice("unknown event from a newer daemon (ignored)".into()),
1050        }
1051    }
1052}
1053
1054// ── per-client decode gate ────────────────────────────────────────────────────
1055
1056/// A client that predates `client_input` (no `owner_reason_input`) on a
1057/// `shared` session. It knows only the owner rule, so the daemon presents
1058/// the truth in those terms: it owns input (its attach snapshot says so,
1059/// unless it observes) and never sees `InputOwnerChanged`. Otherwise it
1060/// would announce "input is owned by …" on attach and "input taken over"
1061/// whenever another client typed, while it can type all along. Matters
1062/// most after a `daemon reload` onto a new build, when every open TUI is
1063/// an older client.
1064pub fn legacy_shared(caps: ClientCapabilities, client_input: ClientInput) -> bool {
1065    client_input == ClientInput::Shared && !caps.owner_reason_input
1066}
1067
1068/// Whether a client that declared `caps` can decode `event` once it is a
1069/// wire frame. The enums nested under `Stream` have no `#[serde(other)]`,
1070/// so an older client does not skip an unknown nested variant: the whole
1071/// frame fails to decode, `SocketTransport::next_event` treats that as a
1072/// dead socket and the TUI drops into reconnect — whose `Attached` replay
1073/// may carry the very same event and fail again. The daemon therefore
1074/// drops, per connection, every event the client did not opt into
1075/// (`daemon/conn.rs`, live pump and attach replay alike).
1076pub fn client_decodes(caps: ClientCapabilities, event: &SessionEventWire) -> bool {
1077    match event {
1078        SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::ToolQueued { .. } | LlmEvent::ToolStarted { .. })) => {
1079            caps.tool_queue
1080        }
1081        _ => true,
1082    }
1083}
1084
1085// ── framing helpers ───────────────────────────────────────────────────────────
1086
1087/// Human-readable form of the cap for error frames.
1088pub fn frame_limit_msg() -> String {
1089    format!("frame exceeds {} MiB limit", DAEMON_MAX_FRAME_BYTES / (1024 * 1024))
1090}
1091
1092/// Encode one frame as a single line (no embedded newline: serde_json
1093/// escapes them). Refuses to produce a line over [`DAEMON_MAX_FRAME_BYTES`].
1094pub fn encode_line<T: Serialize>(frame: &T) -> Result<String, String> {
1095    let mut s = serde_json::to_string(frame).map_err(|e| e.to_string())?;
1096    if s.len() + 1 > DAEMON_MAX_FRAME_BYTES {
1097        return Err(frame_limit_msg());
1098    }
1099    s.push('\n');
1100    Ok(s)
1101}
1102
1103/// Decode one line, enforcing [`DAEMON_MAX_FRAME_BYTES`].
1104pub fn decode_line<'a, T: Deserialize<'a>>(line: &'a str) -> Result<T, String> {
1105    if line.len() > DAEMON_MAX_FRAME_BYTES {
1106        return Err(frame_limit_msg());
1107    }
1108    serde_json::from_str(line).map_err(|e| e.to_string())
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113    use super::*;
1114    use crate::BudgetDimension;
1115    use std::sync::Arc;
1116
1117    fn view() -> RuntimeView {
1118        crate::session::handle::echo::view_for()
1119    }
1120
1121    /// An `AuthState` reply with every nested type present.
1122    fn auth_state_report() -> agent_core::auth::AuthStateReport {
1123        use agent_core::auth::auth_state::{AccountState, CooldownReason, CooldownState, IdentityKind, ObservedOutcome, SelectionState};
1124        use agent_core::auth::quota_policy::{
1125            AccountFailure, AccountFailureKind, ObservationFailure, SelectionFailure, SelectionFailureKind,
1126        };
1127        let usage_429 = ObservationFailure { kind: AccountFailureKind::UsageRateLimited, http_status: Some(429), retry_after_s: Some(120) };
1128        agent_core::auth::AuthStateReport {
1129            schema_version: agent_core::auth::AUTH_STATE_SCHEMA_VERSION,
1130            live: true,
1131            broker_active: true,
1132            generated_at_ms: 1_800_000_000_000,
1133            store: "/scratch/auth.json".into(),
1134            accounts: vec![AccountState {
1135                provider: "anthropic".into(),
1136                label: "a".into(),
1137                identity: IdentityKind::Stable,
1138                access_expires_ms: Some(1_800_000_600_000),
1139                cooldown: Some(CooldownState { until_ms: 1_800_003_600_000, reason: CooldownReason::AnthropicQuotaExhausted }),
1140                last_usage: Some(ObservedOutcome { at_ms: 1_799_999_990_000, failure: Some(usage_429.clone()) }),
1141                last_usage_ok_ms: Some(1_799_999_000_000),
1142                last_refresh: Some(ObservedOutcome { at_ms: 1_799_999_500_000, failure: None }),
1143            }],
1144            selections: vec![SelectionState {
1145                provider: "anthropic".into(),
1146                model: Some("claude-x".into()),
1147                at_ms: 1_799_999_990_000,
1148                chosen: None,
1149                failure: Some(SelectionFailure {
1150                    kind: SelectionFailureKind::UsageReadFailed,
1151                    accounts: vec![AccountFailure {
1152                        account: "a".into(),
1153                        kind: AccountFailureKind::UsageRateLimited,
1154                        http_status: Some(429),
1155                        retry_after_s: Some(120),
1156                        until_ms: None,
1157                        cooling_down: false,
1158                    }],
1159                    earliest_reset_ms: None,
1160                    usage_read_retry_ms: None,
1161                }),
1162                vend_failure: None,
1163            }],
1164            cleared: vec!["anthropic:b".into()],
1165        }
1166    }
1167
1168    fn meta() -> SessionMeta {
1169        crate::session::handle::echo::meta_for(&SessionId::from("wire-1"))
1170    }
1171
1172    fn prompt() -> PromptRequest {
1173        PromptRequest {
1174            id: 3,
1175            kind: PromptKind::Secret,
1176            title: "sudo".into(),
1177            prompt: "password".into(),
1178            raised_at: chrono::Utc::now(),
1179            confirm_scope: None,
1180        }
1181    }
1182
1183    fn conv() -> ConversationSnapshot {
1184        ConversationSnapshot {
1185            header: SessionHeader {
1186                id: "wire-1".into(),
1187                title: "t".into(),
1188                name: None,
1189                model: "m".into(),
1190                thinking_level: "off".into(),
1191                system_prompt: Some("sys".into()),
1192                created_at: chrono::Utc::now(),
1193                updated_at: chrono::Utc::now(),
1194                parent_session: Some("p".into()),
1195            },
1196            api_messages: vec![Arc::new(serde_json::json!({"role":"user","content":"hi"}))],
1197            messages_len: 1,
1198            tokens: ConversationTokens { input: 1, output: 2, cache_read: 3, cache_creation: 4 },
1199            cost: 0.5,
1200            keepwarm_cost: 0.125,
1201            keepwarm_tokens: ConversationTokens { input: 0, output: 0, cache_read: 9, cache_creation: 0 },
1202            abort_context: Some("ctx".into()),
1203            queued_message: None,
1204            pending_events_len: 2,
1205            consecutive_auto_turns: 1,
1206            messages_hash_memo: Default::default(),
1207        }
1208    }
1209
1210    /// One snapshot fanned out to N socket clients is hashed ONCE (the memo
1211    /// is shared by its clones), and a snapshot whose messages were replaced
1212    /// afterwards is re-hashed, never served a stale hash.
1213    #[test]
1214    fn conversation_digest_hash_is_computed_once_and_never_stale() {
1215        let a = conv();
1216        let b = a.clone();
1217        assert_eq!(ConversationDigest::of(&a), ConversationDigest::of(&b));
1218        assert!(a.messages_hash_memo.0.lock().unwrap().is_some(), "memo shared");
1219        assert_eq!(
1220            ConversationDigest::of(&b).messages_hash,
1221            messages_hash(&a.api_messages)
1222        );
1223        let mut c = a.clone();
1224        c.api_messages = Vec::new();
1225        assert_eq!(ConversationDigest::of(&c).messages_hash, messages_hash(&[]));
1226        let mut d = a.clone();
1227        d.api_messages.push(Arc::new(serde_json::json!({"role":"assistant","content":"yo"})));
1228        assert_eq!(
1229            ConversationDigest::of(&d).messages_hash,
1230            messages_hash(&d.api_messages)
1231        );
1232    }
1233
1234    fn env(event: SessionEventWire) -> Envelope {
1235        Envelope { session_id: SessionId::from("wire-1"), seq: 9, ts: chrono::Utc::now(), event }
1236    }
1237
1238    fn stream_fixtures() -> Vec<StreamEvent> {
1239        vec![
1240            StreamEvent::Llm(LlmEvent::Thinking("t".into())),
1241            StreamEvent::Llm(LlmEvent::Text("x\ny".into())),
1242            StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name: "bash".into(), tool_id: "id1".into() }),
1243            StreamEvent::Llm(LlmEvent::ToolUseDelta { tool_id: "id1".into(), delta: "{\"c".into() }),
1244            StreamEvent::Llm(LlmEvent::ToolUse {
1245                tool_name: "bash".into(),
1246                tool_id: "id1".into(),
1247                input: serde_json::json!({"command":"pwd"}),
1248            }),
1249            StreamEvent::Llm(LlmEvent::ToolResult { tool_id: "id1".into(), result: "/tmp".into() }),
1250            StreamEvent::Llm(LlmEvent::ToolResultDelta { tool_id: "id1".into(), delta: "/t".into() }),
1251            StreamEvent::Llm(LlmEvent::ToolQueued { tool_id: "id2".into() }),
1252            StreamEvent::Llm(LlmEvent::ToolStarted { tool_id: "id2".into() }),
1253            StreamEvent::Session(SessionEvent::MessageHistory(vec![Arc::new(serde_json::json!({"role":"user","content":"a"}))])),
1254            StreamEvent::Session(SessionEvent::Usage {
1255                input_tokens: 1,
1256                output_tokens: 2,
1257                cache_read_input_tokens: 3,
1258                cache_creation_input_tokens: 4,
1259                cache_creation_5m: Some(5),
1260                cache_creation_1h: None,
1261                model: Some("m".into()),
1262            }),
1263            StreamEvent::Session(SessionEvent::Done),
1264            StreamEvent::Session(SessionEvent::Error(TurnError::provider("boom", "auth_error", "turn-1-0"))),
1265            StreamEvent::Session(SessionEvent::Error(TurnError::budget(BudgetDimension::ToolCalls))),
1266            StreamEvent::Session(SessionEvent::Error(TurnError { message: "c".into(), outcome: TurnOutcome::Canceled })),
1267            StreamEvent::Session(SessionEvent::Notice("n".into())),
1268            StreamEvent::Session(SessionEvent::Retry("r".into())),
1269            StreamEvent::Agent(AgentEvent::SubagentStart { subagent_id: 1, agent_name: "a".into(), task_preview: "p".into() }),
1270            StreamEvent::Agent(AgentEvent::SubagentUpdate { subagent_id: 1, agent_name: "a".into(), status: "s".into() }),
1271            StreamEvent::Agent(AgentEvent::SubagentDone {
1272                subagent_id: 1,
1273                agent_name: "a".into(),
1274                result_preview: "r".into(),
1275                duration_secs: 1.5,
1276            }),
1277            StreamEvent::Agent(AgentEvent::SteeringDelivered { message: "m".into() }),
1278        ]
1279    }
1280
1281    /// 04-tui I4: a retry rides on the existing `notice` frame with a flag,
1282    /// so a peer that predates it decodes it as a plain notice (it never
1283    /// becomes a malformed frame), and a plain notice's bytes are unchanged.
1284    #[test]
1285    fn a_retry_is_a_flagged_notice_on_the_wire() {
1286        let retry = StreamEvent::Session(SessionEvent::Retry("⚠ Rate limited — retrying (1/5)".into()));
1287        let json = serde_json::to_value(WireStreamEvent::from(retry)).unwrap();
1288        assert_eq!(json["session"], "notice", "{json}");
1289        assert_eq!(json["retry"], true, "{json}");
1290        let back: WireStreamEvent = serde_json::from_value(json).unwrap();
1291        assert!(
1292            matches!(StreamEvent::from(back), StreamEvent::Session(SessionEvent::Retry(t)) if t.starts_with("⚠ Rate limited")),
1293            "a retry stays a retry"
1294        );
1295        let plain = StreamEvent::Session(SessionEvent::Notice("n".into()));
1296        let json = serde_json::to_value(WireStreamEvent::from(plain)).unwrap();
1297        assert!(json.get("retry").is_none(), "a plain notice is unchanged: {json}");
1298        // An older daemon's notice has no flag: a plain notice.
1299        let old: WireStreamEvent =
1300            serde_json::from_value(serde_json::json!({"kind": "session", "session": "notice", "text": "x"})).unwrap();
1301        assert!(matches!(StreamEvent::from(old), StreamEvent::Session(SessionEvent::Notice(t)) if t == "x"));
1302    }
1303
1304    fn fixtures() -> Vec<SessionEventWire> {
1305        use SessionEventWire as S;
1306        let loader = crate::extensions::loader::ExtensionLoaderEvent::Finished {
1307            loaded: vec!["p".into()],
1308            failed: vec![LoadFailure {
1309                plugin: "bad".into(),
1310                manifest_path: Some(PathBuf::from("/x/manifest.json")),
1311                reason: "oops".into(),
1312                hint: "fix".into(),
1313            }],
1314        };
1315        let mut v: Vec<S> = stream_fixtures().into_iter().map(S::Stream).collect();
1316        v.extend([
1317            S::TurnStarted { turn_baseline: 4, trigger: TurnTrigger::EventAuto, user_text: None },
1318            S::TurnStarted { turn_baseline: 5, trigger: TurnTrigger::QueuedAuto, user_text: Some("q".into()) },
1319            S::TurnStarted { turn_baseline: 6, trigger: TurnTrigger::DriverAuto, user_text: None },
1320            S::Conversation(conv()),
1321            S::Prompt(prompt()),
1322            S::PromptResolved { prompt_id: 3 },
1323            S::External(crate::events::types::Event::simple("cli", "hello", None)),
1324            S::AutoTurnCapReached { cap: 5 },
1325            S::Idle,
1326            S::Steered { text: "s".into(), delivered: true },
1327            S::Dequeued { text: "d".into() },
1328            S::SystemNotice("sys".into()),
1329            S::LoaderProgress(loader),
1330            S::ExtensionNotification { extension_id: "e".into(), method: "widget.upsert".into(), params: serde_json::json!({"a":1}) },
1331            S::SettingChanged(SettingApplied {
1332                id: 3,
1333                setting: "model".into(),
1334                ok: true,
1335                message: None,
1336                view: view(),
1337                clamp: Some(ReasoningClampWire { from: "ultra".into(), to: "high".into() }),
1338            }),
1339            S::QueryResult { id: 1, value: serde_json::json!([1, 2]) },
1340            S::Attached {
1341                client: ClientId(2),
1342                snapshot: AttachSnapshot {
1343                    meta: meta(),
1344                    view: view(),
1345                    conversation: conv(),
1346                    streaming: true,
1347                    replay: vec![env(S::Stream(StreamEvent::Llm(LlmEvent::Text("partial".into()))))],
1348                    pending_prompts: vec![prompt()],
1349                    clients: vec![(ClientId(1), ClientKind::Tui)],
1350                    roster: Vec::new(),
1351                    client_input: ClientInput::Shared,
1352                    input_owner: Some(ClientId(1)),
1353                    display_tail: None,
1354                },
1355            },
1356            S::ClientJoined { client: ClientId(1), kind: ClientKind::Attach, name: "discord".into(), mode: AttachMode::Observe },
1357            S::ClientLeft { client: ClientId(1) },
1358            S::Ended { reason: EndReason::HostShutdown },
1359            S::Aborted { context_saved: true },
1360            S::Cleared { session_id: "new-1".into() },
1361            S::CompactionStarted { source: "manual".into(), disclosure: "disc".into() },
1362            S::CompactionApplied {
1363                previous_session_id: "old".into(),
1364                session_id: "new".into(),
1365                chains_advanced: vec!["c1".into()],
1366                queued_restored: Some("q".into()),
1367                msg_count: 12,
1368            },
1369            S::CompactionFailed { message: "boom".into(), panicked: false },
1370            S::CompactionCancelled,
1371            S::SubagentRows(vec![crate::runtime::subagent::SubagentDisplayRow {
1372                subagent_id: 1,
1373                agent_name: "a".into(),
1374                status: crate::runtime::subagent::SubagentStatus::Failed("x".into()),
1375                cancel_requested: false,
1376                elapsed_secs: 1.25,
1377                finished_elapsed: Some(std::time::Duration::from_millis(1250)),
1378                step: "$ cargo test".into(),
1379                tools: 3,
1380                handle_id: "sa_1".into(),
1381                model: "provider/model-a".into(),
1382            }]),
1383            S::Resumed { id: 2, old_id: "o".into(), new_id: "n".into(), via: Some("name".into()), clamp_notice: None },
1384            S::InputOwnerChanged { from: Some(ClientId(1)), to: Some(ClientId(2)), reason: OwnerChangeReason::Takeover },
1385            S::Refused { client: ClientId(2), command: "submit".into(), reason: "input owned by client #1".into() },
1386            S::AttachRefused { message: "parked".into() },
1387            S::Lifecycle(SessionLifecycle::Parked),
1388            S::Reloading { generation: 2, retry_after_ms: 500 },
1389            S::DriverArmed {
1390                plugin_id: "autonomous".into(),
1391                run_id: "run-1".into(),
1392                models: vec![
1393                    crate::extensions::session_driver::Selection { model: "m1".into(), effort: "high".into() },
1394                    crate::extensions::session_driver::Selection { model: "m2".into(), effort: "low".into() },
1395                ],
1396                selection: crate::extensions::session_driver::Selection { model: "m1".into(), effort: "high".into() },
1397                deadline_ms: Some(60_000),
1398                notice: "armed".into(),
1399            },
1400            S::DriverRevoked { reason: "canceled".into(), undelivered_steering: vec!["steer1".into()] },
1401            S::DriverTurnOutcome {
1402                outcome: crate::extensions::session_driver::Outcome::Success,
1403                selection: crate::extensions::session_driver::Selection { model: "m1".into(), effort: "high".into() },
1404                feedback: Some("changed".into()),
1405            },
1406        ]);
1407        v
1408    }
1409
1410    fn queued_frame() -> String {
1411        let ev = env(SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::ToolQueued { tool_id: "toolu_2".into() })));
1412        encode_line(&DaemonFrame::Event(ev.into())).unwrap()
1413    }
1414
1415    /// What an OLDER client does with a new nested `llm` variant: its
1416    /// `WireLlmEvent` lacks the tag, so serde fails the WHOLE frame (no
1417    /// `#[serde(other)]` down there) — `SocketTransport::next_event` then
1418    /// ends the stream as if the socket closed. Simulated the way the
1419    /// driver-event test does: rename the tag to one this build lacks.
1420    /// Only the top level (`ev`) has an `Unknown` fallback, and that one
1421    /// shows the user a notice.
1422    #[test]
1423    fn an_unknown_nested_llm_variant_fails_the_whole_frame() {
1424        let line = queued_frame();
1425        assert!(line.contains(r#""llm":"tool_queued""#), "{line}");
1426        let as_old = line.replace(r#""llm":"tool_queued""#, r#""llm":"tool_from_the_future""#);
1427        let err = decode_line::<DaemonFrame>(as_old.trim_end()).unwrap_err();
1428        assert!(err.contains("unknown variant `tool_from_the_future`"), "{err}");
1429        // The same inside an attach replay sinks the whole `Attached`.
1430        let attached = AttachedWire::new(
1431            ClientId(1),
1432            AttachSnapshot {
1433                meta: meta(),
1434                view: view(),
1435                conversation: conv(),
1436                streaming: true,
1437                replay: vec![env(SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::ToolQueued { tool_id: "toolu_2".into() })))],
1438                pending_prompts: vec![],
1439                clients: vec![],
1440                roster: Vec::new(),
1441                client_input: ClientInput::Shared,
1442                input_owner: None,
1443                display_tail: None,
1444            },
1445        );
1446        let line = encode_line(&DaemonFrame::Attached(attached)).unwrap();
1447        let as_old = line.replace(r#""llm":"tool_queued""#, r#""llm":"tool_from_the_future""#);
1448        assert!(decode_line::<DaemonFrame>(as_old.trim_end()).is_err());
1449        // Whereas an unknown TOP-level event decodes, as a user-visible notice.
1450        let top_frame = queued_frame().replace(r#""ev":"stream""#, r#""ev":"zz_new""#);
1451        match decode_line::<DaemonFrame>(top_frame.trim_end()).unwrap() {
1452            DaemonFrame::Event(w) => match Envelope::from(w).event {
1453                SessionEventWire::SystemNotice(t) => assert!(t.contains("newer daemon"), "{t}"),
1454                other => panic!("{other:?}"),
1455            },
1456            other => panic!("{other:?}"),
1457        }
1458    }
1459
1460    /// `client_input` on the wire: a current daemon sends it; an `Attached`
1461    /// from an older daemon (no field) means `Exclusive`, the owner rule it
1462    /// enforces. Only a client without `owner_reason_input` on a shared
1463    /// session is presented the owner rule.
1464    #[test]
1465    fn client_input_absent_means_exclusive_and_legacy_clients_are_presented_owners() {
1466        let snap = |client_input| AttachSnapshot {
1467            meta: meta(),
1468            view: view(),
1469            conversation: conv(),
1470            streaming: false,
1471            replay: vec![],
1472            pending_prompts: vec![],
1473            clients: vec![],
1474            roster: Vec::new(),
1475            client_input,
1476            input_owner: Some(ClientId(1)),
1477            display_tail: None,
1478        };
1479        let line = encode_line(&DaemonFrame::Attached(AttachedWire::new(ClientId(2), snap(ClientInput::Shared)))).unwrap();
1480        assert!(line.contains(r#""client_input":"shared""#), "{line}");
1481        let decoded = |line: &str| match decode_line::<DaemonFrame>(line.trim_end()).unwrap() {
1482            DaemonFrame::Attached(a) => a.into_snapshot().1,
1483            other => panic!("{other:?}"),
1484        };
1485        assert_eq!(decoded(&line).client_input, ClientInput::Shared);
1486        let old = line.replace(r#""client_input":"shared","#, "");
1487        assert!(!old.contains("client_input"), "{old}");
1488        let s = decoded(&old);
1489        assert_eq!(s.client_input, ClientInput::Exclusive);
1490        assert!(s.input_owned_elsewhere(ClientId(2)).is_some(), "an older daemon's non-owner is read-only");
1491
1492        let (old_caps, new_caps) = (ClientCapabilities::default(), ClientCapabilities::current());
1493        assert!(legacy_shared(old_caps, ClientInput::Shared));
1494        assert!(!legacy_shared(new_caps, ClientInput::Shared));
1495        assert!(!legacy_shared(old_caps, ClientInput::Exclusive), "exclusive is the rule it knows");
1496    }
1497
1498    /// The gate: a client that did not declare `tool_queue` (every client
1499    /// that predates it) is never sent the queue signals; everything else
1500    /// it decodes exactly as before. A current client gets them.
1501    #[test]
1502    fn queue_signals_reach_only_clients_that_opted_in() {
1503        let old = ClientCapabilities::default();
1504        let new = ClientCapabilities::current();
1505        for ev in fixtures() {
1506            let queue = matches!(
1507                ev,
1508                SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::ToolQueued { .. } | LlmEvent::ToolStarted { .. }))
1509            );
1510            assert_eq!(client_decodes(old, &ev), !queue, "old client: {ev:?}");
1511            assert!(client_decodes(new, &ev), "new client: {ev:?}");
1512        }
1513    }
1514
1515    /// Hello compat both ways: an older client's meta has no capabilities
1516    /// (decodes as none), a current one declares `tool_queue`, and an older
1517    /// daemon's `ClientMeta` (no such field) still decodes the new frame.
1518    #[test]
1519    fn client_capabilities_default_off_and_old_daemons_ignore_them() {
1520        let legacy = r#"{"kind":"tui","terminal":null,"instance":"i"}"#;
1521        let m: ClientMeta = serde_json::from_str(legacy).unwrap();
1522        assert_eq!(m.capabilities, ClientCapabilities::default());
1523        assert!(!m.capabilities.tool_queue);
1524
1525        let h = Hello::new(ClientKind::Tui);
1526        assert!(h.client.capabilities.tool_queue);
1527        let line = encode_line(&ClientFrame::Hello(h)).unwrap();
1528        assert!(line.contains(r#""capabilities":{"tool_queue":true,"owner_reason_input":true}"#), "{line}");
1529        match decode_line::<ClientFrame>(line.trim_end()).unwrap() {
1530            ClientFrame::Hello(h) => assert!(h.client.capabilities.tool_queue),
1531            other => panic!("{other:?}"),
1532        }
1533
1534        // The ClientMeta an older daemon decodes (as of da1eb372).
1535        #[derive(Deserialize)]
1536        #[allow(dead_code)]
1537        struct OldClientMeta {
1538            kind: ClientKind,
1539            terminal: Option<String>,
1540            instance: String,
1541            #[serde(default)]
1542            history: HistoryMode,
1543            #[serde(default)]
1544            tail_items: usize,
1545        }
1546        let json = serde_json::to_string(&ClientMeta::new(ClientKind::Tui)).unwrap();
1547        let old: OldClientMeta = serde_json::from_str(&json).unwrap();
1548        assert_eq!(old.kind, ClientKind::Tui);
1549    }
1550
1551    /// Client names, both directions: an older daemon's `ClientJoined` (no
1552    /// name, no mode) decodes as the kind's name in `Mirror`; a `Hello`
1553    /// without a name sends no `name` key (older daemons see the old
1554    /// bytes), with one it round-trips.
1555    #[test]
1556    fn client_names_are_additive_on_the_wire() {
1557        let old = r#"{"ev":"client_joined","client":4,"kind":"attach"}"#;
1558        let ev: SessionEventWire = serde_json::from_str::<WireSessionEvent>(old).unwrap().into();
1559        let mut roster = Roster::default();
1560        roster.apply(&ev);
1561        let c = roster.get(ClientId(4)).unwrap();
1562        assert_eq!((c.name.as_str(), c.mode), ("attach", AttachMode::Mirror));
1563
1564        let unnamed = encode_line(&ClientFrame::Hello(Hello::new(ClientKind::Tui))).unwrap();
1565        assert!(!unnamed.contains(r#""name""#), "{unnamed}");
1566        let named = Hello::new(ClientKind::Tui).with_name(Some("discord".into()));
1567        let line = encode_line(&ClientFrame::Hello(named)).unwrap();
1568        match decode_line::<ClientFrame>(line.trim_end()).unwrap() {
1569            ClientFrame::Hello(h) => assert_eq!(h.client.name.as_deref(), Some("discord")),
1570            other => panic!("{other:?}"),
1571        }
1572    }
1573
1574    /// The watch query's opt-in: an older client's query omits it (off), an
1575    /// off flag is not serialized (an older daemon sees the old bytes).
1576    #[test]
1577    fn the_watch_queries_tool_queue_flag_defaults_off() {
1578        let old: SessionQuery =
1579            serde_json::from_str(r#"{"query":"subagent_watch","handle_id":"sa_1","since":4}"#).unwrap();
1580        assert!(matches!(old, SessionQuery::SubagentWatch { tool_queue: false, since: 4, .. }));
1581        let off = SessionQuery::SubagentWatch { handle_id: "sa_1".into(), since: 4, tool_queue: false };
1582        assert!(!serde_json::to_string(&off).unwrap().contains("tool_queue"));
1583        let on = SessionQuery::SubagentWatch { handle_id: "sa_1".into(), since: 4, tool_queue: true };
1584        let back: SessionQuery = serde_json::from_str(&serde_json::to_string(&on).unwrap()).unwrap();
1585        assert!(matches!(back, SessionQuery::SubagentWatch { tool_queue: true, .. }));
1586    }
1587
1588    #[test]
1589    fn wire_roundtrip_every_variant() {
1590        let all = fixtures();
1591        // 36 non-Stream variants (TurnStarted thrice = 3; +3 driver events) + 21 StreamEvent leaves.
1592        assert_eq!(all.len(), 36 + 21);
1593        for ev in all {
1594            let is_conv = matches!(ev, SessionEventWire::Conversation(_));
1595            let e = env(ev);
1596            let before = format!("{e:?}");
1597            let wire: WireEnvelope = e.into();
1598            let line = encode_line(&wire).unwrap();
1599            assert_eq!(line.matches('\n').count(), 1, "one line per frame");
1600            let back: WireEnvelope = decode_line(line.trim_end()).unwrap();
1601            let after = format!("{:?}", Envelope::from(back));
1602            if is_conv {
1603                // Digest on the wire: everything but the messages survives.
1604                assert!(!line.contains("api_messages"), "{line}");
1605                assert!(after.contains("api_messages: []"), "{after}");
1606                assert_eq!(before.replace("api_messages: [Object {\"content\": String(\"hi\"), \"role\": String(\"user\")}]", "api_messages: []"), after);
1607            } else {
1608                assert_eq!(before, after);
1609            }
1610        }
1611    }
1612
1613    #[test]
1614    fn hello_history_fields_default_and_roundtrip() {
1615        // Old peers omit the fields: Full / 120.
1616        let legacy = r#"{"kind":"tui","terminal":null,"instance":"i"}"#;
1617        let m: ClientMeta = serde_json::from_str(legacy).unwrap();
1618        assert_eq!(m.history, HistoryMode::Full);
1619        assert_eq!(m.tail_items, DEFAULT_TAIL_ITEMS);
1620        let h = Hello::new(ClientKind::Tui).with_history(HistoryMode::Digest).with_tail_items(9);
1621        let line = encode_line(&ClientFrame::Hello(h)).unwrap();
1622        assert!(line.contains("\"history\":\"digest\""), "{line}");
1623        assert!(line.contains("\"tail_items\":9"), "{line}");
1624        match decode_line::<ClientFrame>(line.trim_end()).unwrap() {
1625            ClientFrame::Hello(h) => {
1626                assert_eq!(h.client.history, HistoryMode::Digest);
1627                assert_eq!(h.client.tail_items, 9);
1628            }
1629            other => panic!("{other:?}"),
1630        }
1631        // Digest snapshots keep messages_len even with api_messages empty.
1632        let d = ConversationDigest::of(&conv());
1633        let snap = d.into_snapshot(Vec::new());
1634        assert_eq!(snap.messages_len, 1);
1635        assert!(snap.api_messages.is_empty());
1636        let legacy: ConversationSnapshot = serde_json::from_str(r#"{"api_messages":[],"tokens":{"input":0,"output":0,"cache_read":0,"cache_creation":0},"cost":0.0,"abort_context":null,"queued_message":null,"pending_events_len":0,"consecutive_auto_turns":0}"#).unwrap();
1637        assert_eq!(legacy.messages_len, 0);
1638    }
1639
1640    #[test]
1641    fn conversation_digest_roundtrips_and_detects_drift() {
1642        let c = conv();
1643        let d = ConversationDigest::of(&c);
1644        assert_eq!(d.messages_len, 1);
1645        assert!(d.matches(&c.api_messages));
1646        assert!(!d.matches(&[]));
1647        let mut other = c.api_messages.clone();
1648        other.push(Arc::new(serde_json::json!({"role":"assistant","content":"x"})));
1649        assert!(!d.matches(&other));
1650        let back = d.clone().into_snapshot(c.api_messages.clone());
1651        assert_eq!(ConversationDigest::of(&back), d);
1652        // stable across runs/binaries: FNV-1a offset basis for the empty list
1653        assert_eq!(messages_hash(&[]), 0xcbf2_9ce4_8422_2325);
1654        assert_ne!(messages_hash(&c.api_messages), messages_hash(&[]));
1655    }
1656
1657    #[test]
1658    fn unknown_event_variant_tolerated() {
1659        let v: WireSessionEvent = serde_json::from_str(r#"{"ev":"from_the_future","x":1}"#).unwrap();
1660        assert!(matches!(v, WireSessionEvent::Unknown));
1661        assert!(matches!(SessionEventWire::from(v), SessionEventWire::SystemNotice(_)));
1662    }
1663
1664    /// An older daemon cannot decode a newer client's command; serde names
1665    /// the unknown tag in backticks. The TUI keys on exactly that
1666    /// (`tui::watch::absorb_notice`, "malformed frame: unknown variant
1667    /// `subagent_watch`") to tell the user to reload the daemon.
1668    #[test]
1669    fn an_unknown_command_or_query_names_its_tag_in_the_decode_error() {
1670        let frames = [
1671            (
1672                ClientFrame::Cmd {
1673                    session_id: "s".into(),
1674                    cmd: SessionCommand::Query {
1675                        id: 1,
1676                        query: SessionQuery::SubagentWatch { handle_id: "sa_1".into(), since: 0, tool_queue: false },
1677                    },
1678                },
1679                "subagent_watch",
1680            ),
1681            (
1682                ClientFrame::Cmd {
1683                    session_id: "s".into(),
1684                    cmd: SessionCommand::SubagentSteer { handle_id: "sa_1".into(), text: "x".into() },
1685                },
1686                "subagent_steer",
1687            ),
1688        ];
1689        for (frame, tag) in frames {
1690            let json = serde_json::to_string(&frame).unwrap();
1691            assert!(decode_line::<ClientFrame>(&json).is_ok());
1692            // What an older daemon sees: a tag it does not know.
1693            let old = json.replace(&format!("\"{tag}\""), "\"zz_from_the_future\"");
1694            assert_ne!(old, json, "{tag} is serialized as a quoted tag");
1695            let err = decode_line::<ClientFrame>(&old).unwrap_err();
1696            assert!(err.contains("unknown variant `zz_from_the_future`"), "{err}");
1697        }
1698    }
1699
1700    #[test]
1701    fn driver_armed_round_trips_and_old_client_skips() {
1702        // A v3 daemon emits DriverArmed; a v2 client decodes it as Unknown.
1703        let armed = WireSessionEvent::DriverArmed {
1704            plugin_id: "autonomous".into(),
1705            run_id: "run-42".into(),
1706            models: vec![
1707                crate::extensions::session_driver::Selection { model: "m1".into(), effort: "high".into() },
1708            ],
1709            selection: crate::extensions::session_driver::Selection { model: "m1".into(), effort: "high".into() },
1710            deadline_ms: Some(120_000),
1711            notice: "armed and ready".into(),
1712        };
1713        let json = serde_json::to_string(&armed).unwrap();
1714        // v3 client: exact round-trip.
1715        let back: WireSessionEvent = serde_json::from_str(&json).unwrap();
1716        assert!(matches!(&back, WireSessionEvent::DriverArmed { plugin_id, .. } if plugin_id == "autonomous"));
1717        // Simulated v2 client: rename tag so it hits Unknown.
1718        let old_json = json.replace(r#""ev":"driver_armed""#, r#""ev":"v2_unknown_event""#);
1719        let old: WireSessionEvent = serde_json::from_str(&old_json).unwrap();
1720        assert!(matches!(old, WireSessionEvent::Unknown));
1721    }
1722
1723    #[test]
1724    fn driver_revoked_and_outcome_round_trip() {
1725        let revoked = WireSessionEvent::DriverRevoked {
1726            reason: "canceled".into(),
1727            undelivered_steering: vec!["steer1".into(), "steer2".into()],
1728        };
1729        let json = serde_json::to_string(&revoked).unwrap();
1730        let back: WireSessionEvent = serde_json::from_str(&json).unwrap();
1731        assert!(matches!(&back, WireSessionEvent::DriverRevoked { reason, undelivered_steering }
1732            if reason == "canceled" && undelivered_steering.len() == 2));
1733
1734        let outcome = WireSessionEvent::DriverTurnOutcome {
1735            outcome: crate::extensions::session_driver::Outcome::Success,
1736            selection: crate::extensions::session_driver::Selection { model: "m".into(), effort: "high".into() },
1737            feedback: Some("changed".into()),
1738        };
1739        let json = serde_json::to_string(&outcome).unwrap();
1740        let back: WireSessionEvent = serde_json::from_str(&json).unwrap();
1741        assert!(matches!(&back, WireSessionEvent::DriverTurnOutcome { feedback: Some(f), .. } if f == "changed"));
1742    }
1743
1744    #[test]
1745    fn driver_revoked_defaults_tolerate_missing_fields() {
1746        // Old daemon or minimal frame: undelivered_steering missing → empty vec.
1747        let minimal = r#"{"ev":"driver_revoked","reason":"timeout"}"#;
1748        let v: WireSessionEvent = serde_json::from_str(minimal).unwrap();
1749        assert!(matches!(&v, WireSessionEvent::DriverRevoked { reason, undelivered_steering }
1750            if reason == "timeout" && undelivered_steering.is_empty()));
1751    }
1752
1753    #[test]
1754    fn client_frames_roundtrip_and_daemon_frames_roundtrip() {
1755        let frames = vec![
1756            ClientFrame::Hello(Hello::new(ClientKind::Attach)),
1757            ClientFrame::Hello(Hello::new(ClientKind::Tui).with_history(HistoryMode::Digest).with_tail_items(7)),
1758            ClientFrame::Ping,
1759            ClientFrame::Sessions,
1760            ClientFrame::Shutdown { force: true },
1761            ClientFrame::Attach(Attach::Existing { session_id: "s".into(), mode: AttachMode::Mirror }),
1762            ClientFrame::Attach(Attach::Create { config: SessionConfig::default(), mode: AttachMode::Takeover }),
1763            ClientFrame::Cmd { session_id: "s".into(), cmd: SessionCommand::Submit { text: "hi".into(), attachments: vec![] } },
1764            ClientFrame::Cmd { session_id: "s".into(), cmd: SessionCommand::Answer { prompt_id: 1, value: Some("pw".into()) } },
1765            ClientFrame::Cmd { session_id: "s".into(), cmd: SessionCommand::EngineCommand { id: 4, name: "model".into(), arg: "x".into() } },
1766            ClientFrame::Cmd { session_id: "s".into(), cmd: SessionCommand::Query { id: 5, query: SessionQuery::Status } },
1767            ClientFrame::Cmd { session_id: "s".into(), cmd: SessionCommand::Set { id: 6, setting: SessionSetting::ReloadPrompt } },
1768            ClientFrame::Cmd { session_id: "s".into(), cmd: SessionCommand::Checkpoint { reason: CheckpointReason::Reload } },
1769            ClientFrame::Cmd { session_id: "s".into(), cmd: SessionCommand::KeepWarm { on: true } },
1770            ClientFrame::Cmd { session_id: "s".into(), cmd: SessionCommand::DriverStart {
1771                plugin: "autonomous".into(), command: "auto".into(), arg: "start -- do stuff".into(),
1772            }},
1773            ClientFrame::Reload { now: true, drain_secs: Some(3), exe: Some(PathBuf::from("/bin/synaps")) },
1774            ClientFrame::Purge,
1775            ClientFrame::Close { session_id: "s".into(), force: true, attached: true },
1776            ClientFrame::Close { session_id: "s".into(), force: false, attached: false },
1777            ClientFrame::Pin { session_id: "s".into(), on: true },
1778            ClientFrame::Pin { session_id: "s".into(), on: false },
1779            ClientFrame::AuthState(agent_core::auth::AuthStateQuery::default()),
1780            ClientFrame::AuthState(agent_core::auth::AuthStateQuery {
1781                provider: Some("anthropic".into()),
1782                account: Some("b".into()),
1783                clear: true,
1784            }),
1785            ClientFrame::ReloadExtension { extension: "subconscious".into() },
1786            ClientFrame::Bye,
1787        ];
1788        for f in frames {
1789            let line = encode_line(&f).unwrap();
1790            let back: ClientFrame = decode_line(line.trim_end()).unwrap();
1791            assert_eq!(serde_json::to_string(&back).unwrap(), line.trim_end());
1792        }
1793        let frames = vec![
1794            DaemonFrame::Welcome(Welcome {
1795                protocol_version: PROTOCOL_VERSION,
1796                daemon_version: "0.9.0".into(),
1797                pid: 1,
1798                profile: None,
1799                sessions: vec![meta()],
1800                progressive_tool_disclosure: true,
1801                generation: 1,
1802                daemon_build: "abc1234".into(),
1803            }),
1804            DaemonFrame::Refused { reason: RefuseReason::Version { daemon_version: 2, min: 2, max: 2 }, message: "no".into() },
1805            DaemonFrame::Refused { reason: RefuseReason::ReloadRefused { why: "older".into() }, message: "no".into() },
1806            DaemonFrame::Pong { pid: 1, uptime_s: 2, sessions: 0 },
1807            DaemonFrame::SessionList { sessions: vec![meta()] },
1808            DaemonFrame::Attached(AttachedWire::new(
1809                ClientId(1),
1810                AttachSnapshot {
1811                    meta: meta(),
1812                    view: view(),
1813                    conversation: conv(),
1814                    streaming: false,
1815                    replay: vec![],
1816                    pending_prompts: vec![],
1817                    clients: vec![],
1818                    roster: Vec::new(),
1819                    client_input: ClientInput::Shared,
1820                    input_owner: None,
1821                    display_tail: None,
1822                },
1823            )),
1824            DaemonFrame::Event(env(SessionEventWire::SystemNotice("x".into())).into()),
1825            DaemonFrame::Error { session_id: None, message: "e".into() },
1826            DaemonFrame::Closed { session_id: "s".into(), text: "closed".into() },
1827            DaemonFrame::Pinned { session_id: "s".into(), on: true },
1828            DaemonFrame::Pinned { session_id: "s".into(), on: false },
1829            DaemonFrame::ExtensionReloaded {
1830                report: crate::extensions::manager::ExtensionReloadReport {
1831                    extension: "subconscious".into(),
1832                    was_loaded: true,
1833                    deferred: false,
1834                    tools: vec!["subconscious:day_note".into()],
1835                    removed_tools: vec!["subconscious:old".into()],
1836                    plugin_dir: PathBuf::from("/p/subconscious"),
1837                },
1838            },
1839            DaemonFrame::ExtensionReloadFailed { extension: "x".into(), message: "boom".into(), unloaded: true },
1840            DaemonFrame::Bye { reason: None },
1841            DaemonFrame::Bye { reason: Some(ByeReason::Reloading { generation: 2, retry_after_ms: 500 }) },
1842            DaemonFrame::Bye { reason: Some(ByeReason::Shutdown) },
1843            DaemonFrame::AuthState { report: auth_state_report() },
1844        ];
1845        for f in frames {
1846            let line = encode_line(&f).unwrap();
1847            let back: DaemonFrame = decode_line(line.trim_end()).unwrap();
1848            assert_eq!(serde_json::to_string(&back).unwrap(), line.trim_end());
1849        }
1850    }
1851
1852    #[test]
1853    fn hello_must_be_first_is_a_type_level_fact_and_version_is_exact() {
1854        let h = Hello::new(ClientKind::Test);
1855        assert_eq!(h.protocol_version, PROTOCOL_VERSION);
1856        assert_eq!(PROTOCOL_MIN, PROTOCOL_MAX);
1857        let json = serde_json::to_string(&ClientFrame::Hello(h)).unwrap();
1858        assert!(json.starts_with(r#"{"type":"hello""#));
1859        assert_eq!(PROTOCOL_VERSION, 3);
1860        // v1 frames (no `reconnect_of`, bare `bye`) still parse — the refusal is by version, not by shape.
1861        let h: Hello = serde_json::from_str(
1862            r#"{"protocol_version":1,"client":{"kind":"tui","terminal":null,"instance":"i"},"cwd":"/","client_version":"0"}"#,
1863        )
1864        .unwrap();
1865        assert!(h.reconnect_of.is_none());
1866        // Legacy Hello without env field → None (backwards compatible).
1867        assert!(h.env.is_none());
1868        // Hello::new captures env.
1869        let h2 = Hello::new(ClientKind::Test);
1870        assert!(h2.env.is_some(), "Hello::new must capture client env");
1871        let b: DaemonFrame = serde_json::from_str(r#"{"type":"bye"}"#).unwrap();
1872        assert!(matches!(b, DaemonFrame::Bye { reason: None }));
1873    }
1874
1875    #[test]
1876    fn close_defaults_force_off_and_older_daemons_reject_it_as_an_unknown_variant() {
1877        let f: ClientFrame = decode_line(r#"{"type":"close","session_id":"s"}"#).unwrap();
1878        assert!(matches!(f, ClientFrame::Close { ref session_id, force: false, attached: false } if session_id.as_str() == "s"));
1879        // A daemon built before `Close` decodes into an enum without it; the
1880        // client keys "daemon too old" on this exact serde wording.
1881        #[derive(Debug, Deserialize)]
1882        #[serde(tag = "type", rename_all = "snake_case")]
1883        enum OldClientFrame {
1884            Ping,
1885            Purge,
1886        }
1887        let err = decode_line::<OldClientFrame>(r#"{"type":"close","session_id":"s","force":true}"#).unwrap_err();
1888        assert!(err.contains("unknown variant `close`"), "{err}");
1889    }
1890
1891    #[test]
1892    fn pin_requires_on_and_older_daemons_reject_it_as_an_unknown_variant() {
1893        let f: ClientFrame = decode_line(r#"{"type":"pin","session_id":"s","on":false}"#).unwrap();
1894        assert!(matches!(f, ClientFrame::Pin { ref session_id, on: false } if session_id.as_str() == "s"));
1895        assert!(decode_line::<ClientFrame>(r#"{"type":"pin","session_id":"s"}"#).is_err());
1896        #[derive(Debug, Deserialize)]
1897        #[serde(tag = "type", rename_all = "snake_case")]
1898        enum OldClientFrame {
1899            Ping,
1900            Purge,
1901        }
1902        let err = decode_line::<OldClientFrame>(r#"{"type":"pin","session_id":"s","on":true}"#).unwrap_err();
1903        assert!(err.contains("unknown variant `pin`"), "{err}");
1904    }
1905
1906    #[test]
1907    fn reload_extension_requires_a_name_and_older_daemons_reject_it_as_an_unknown_variant() {
1908        let f: ClientFrame = decode_line(r#"{"type":"reload_extension","extension":"axel"}"#).unwrap();
1909        assert!(matches!(f, ClientFrame::ReloadExtension { ref extension } if extension == "axel"));
1910        assert!(decode_line::<ClientFrame>(r#"{"type":"reload_extension"}"#).is_err());
1911        // An older daemon's reply-less failure mode: `Connected::reload_extension`
1912        // keys "daemon too old" on this exact wording.
1913        #[derive(Debug, Deserialize)]
1914        #[serde(tag = "type", rename_all = "snake_case")]
1915        enum OldClientFrame {
1916            Ping,
1917            Purge,
1918        }
1919        let err = decode_line::<OldClientFrame>(r#"{"type":"reload_extension","extension":"axel"}"#).unwrap_err();
1920        assert!(err.contains("unknown variant `reload_extension`"), "{err}");
1921        // `unloaded` is optional on the failure reply.
1922        let d: DaemonFrame = serde_json::from_str(r#"{"type":"extension_reload_failed","extension":"x","message":"m"}"#).unwrap();
1923        assert!(matches!(d, DaemonFrame::ExtensionReloadFailed { unloaded: false, .. }));
1924    }
1925
1926    #[test]
1927    fn a_session_meta_without_pinned_decodes_as_not_pinned() {
1928        let mut v = serde_json::to_value(meta()).unwrap();
1929        v.as_object_mut().unwrap().remove("pinned");
1930        let m: SessionMeta = serde_json::from_value(v).unwrap();
1931        assert!(!m.pinned);
1932        let mut pinned = meta();
1933        pinned.pinned = true;
1934        let back: SessionMeta = serde_json::from_value(serde_json::to_value(&pinned).unwrap()).unwrap();
1935        assert!(back.pinned);
1936    }
1937
1938    #[test]
1939    fn debug_redacts_answer_and_submit_bodies() {
1940        let secret = "hunter2-very-secret";
1941        let f = ClientFrame::Cmd {
1942            session_id: "s".into(),
1943            cmd: SessionCommand::Answer { prompt_id: 1, value: Some(secret.into()) },
1944        };
1945        let d = format!("{f:?}");
1946        assert!(!d.contains(secret), "{d}");
1947        assert!(d.contains("redacted"));
1948        let f = ClientFrame::Cmd {
1949            session_id: "s".into(),
1950            cmd: SessionCommand::Submit { text: "my private prompt".into(), attachments: vec![] },
1951        };
1952        let d = format!("{f:?}");
1953        assert!(!d.contains("private"), "{d}");
1954        let f = ClientFrame::Cmd { session_id: "s".into(), cmd: SessionCommand::Steer { text: "steer me".into() } };
1955        let d = format!("{f:?}");
1956        assert!(!d.contains("steer me"));
1957        // No lengths either: a char count is a side channel.
1958        assert!(!d.contains("chars"), "{d}");
1959        assert!(!d.contains('8'), "{d}");
1960    }
1961
1962    #[test]
1963    fn oversize_frame_rejected_both_directions() {
1964        const { assert!(DAEMON_MAX_FRAME_BYTES > RPC_MAX_FRAME_BYTES) };
1965        let big = "x".repeat(DAEMON_MAX_FRAME_BYTES + 1);
1966        assert!(decode_line::<ClientFrame>(&big)
1967            .unwrap_err()
1968            .contains("64 MiB"));
1969        // encode side: a frame that would serialise past the cap is refused
1970        let f = DaemonFrame::Error { session_id: None, message: big };
1971        assert!(encode_line(&f).unwrap_err().contains("64 MiB"));
1972        // a > 1 MiB (rpc cap) frame is fine on the daemon wire
1973        let f = DaemonFrame::Error { session_id: None, message: "y".repeat(RPC_MAX_FRAME_BYTES * 2) };
1974        let line = encode_line(&f).unwrap();
1975        assert!(decode_line::<DaemonFrame>(line.trim_end()).is_ok());
1976    }
1977
1978    #[test]
1979    fn build_id_is_set_once_by_the_binary() {
1980        // The only caller in this test binary; `main` is the only one in `synaps`.
1981        set_build_id(" abc1234-dirty \n");
1982        assert_eq!(build_id(), "abc1234-dirty");
1983        set_build_id("later");
1984        assert_eq!(build_id(), "abc1234-dirty", "first call wins");
1985    }
1986
1987    #[test]
1988    fn no_secret_bearing_fields_on_handshake_frames() {
1989        // Structural guard: the JSON of Welcome/Attached must not contain key-ish names.
1990        let w = serde_json::to_string(&Welcome {
1991            protocol_version: 1,
1992            daemon_version: "v".into(),
1993            pid: 1,
1994            profile: Some("p".into()),
1995            sessions: vec![meta()],
1996            progressive_tool_disclosure: false,
1997            generation: 1,
1998            daemon_build: String::new(),
1999        })
2000        .unwrap()
2001        .to_lowercase();
2002        for bad in ["token", "secret", "api_key", "apikey", "password", "credential"] {
2003            assert!(!w.contains(bad), "{bad} in Welcome");
2004        }
2005    }
2006}
2007
2008#[cfg(test)]
2009mod float_roundtrip_tests {
2010    use super::*;
2011
2012    /// L≡S (phase 4 G9): the session cost crosses the wire as an f64. Without
2013    /// serde_json's `float_roundtrip` the parse is one ulp short for values
2014    /// like `0.000105 + 0.000045` and the footer rounds `$0.0002` → `$0.0001`.
2015    #[test]
2016    fn conversation_digest_cost_survives_the_wire_exactly() {
2017        let cost = (10f64 / 1_000_000.0) * 3.0 + (5f64 / 1_000_000.0) * 15.0
2018            + ((10f64 / 1_000_000.0) * 3.0 + (1f64 / 1_000_000.0) * 15.0);
2019        let snap = ConversationSnapshot { cost, ..Default::default() };
2020        let d = ConversationDigest::of(&snap);
2021        let line = encode_line(&d).unwrap();
2022        let back: ConversationDigest = decode_line(line.trim_end()).unwrap();
2023        assert_eq!(back.cost.to_bits(), cost.to_bits(), "{line}");
2024        assert_eq!(format!("{:.4}", back.cost), format!("{:.4}", cost));
2025    }
2026}