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