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