Skip to main content

agent_engine/session/
socket_transport.rs

1//! `SocketTransport` — the client side of the daemon UDS (PLAN-phase2 §2.10).
2//!
3//! Line-JSON frames (`wire.rs`). A writer task owns the write half; the
4//! reader lives on the transport. Backpressure mirrors `LocalTransport`:
5//! `send` fails with `Backpressure` when the bounded writer queue is full and
6//! `Closed` once the socket is gone — never a silent drop.
7//!
8//! Tracing here logs frame *types* only (`ClientFrame`'s redacting `Debug`).
9
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use std::time::Duration;
13
14use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
15use tokio::sync::mpsc;
16
17use super::handle::CMD_CHAN_CAP;
18use super::transport::{ClientTransport, TransportError, ATTACH_TIMEOUT, ATTACH_TIMEOUT_PARKED};
19use super::types::*;
20use super::view::RuntimeView;
21use super::wire::*;
22use crate::ipc::{ClientReadHalf, ClientWriteHalf};
23
24pub const CONNECT_TIMEOUT: Duration = Duration::from_millis(500);
25pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(2);
26/// How long `close` waits for the daemon's answer: its queue retry, the
27/// actor's reply and the session's end (`daemon/close.rs` budgets), plus a
28/// margin.
29pub const CLOSE_TIMEOUT: Duration = Duration::from_secs(
30    crate::daemon::close::CLOSE_SEND_RETRY.as_secs()
31        + crate::daemon::close::CLOSE_REPLY_BUDGET.as_secs()
32        + crate::daemon::close::CLOSE_END_BUDGET.as_secs()
33        + 2,
34);
35/// How long `pin` waits for the daemon's answer: its longest wait (a parked
36/// session unparking) plus a margin.
37pub const PIN_TIMEOUT: Duration =
38    Duration::from_secs(crate::daemon::pin::PIN_UNPARK_BUDGET.as_secs() + 2);
39/// How long `rename` waits for the daemon's answer: its queue retry and the
40/// actor's reply (`daemon/rename.rs` budgets), plus a margin.
41pub const RENAME_TIMEOUT: Duration = Duration::from_secs(
42    crate::daemon::rename::RENAME_SEND_RETRY.as_secs() + crate::daemon::rename::RENAME_REPLY_BUDGET.as_secs() + 2,
43);
44/// How long `reload_extension` waits: the daemon first waits for the
45/// extension manager's lock, then stops the old process and runs the new
46/// one's start-up handshake (each bounded by the extension runtime's own
47/// timeouts, around 15 s together). Generous, because giving up early only
48/// hides the answer: the daemon finishes the reload either way.
49pub const RELOAD_EXTENSION_TIMEOUT: Duration = Duration::from_secs(60);
50
51/// A connection past `Hello`/`Welcome`, before `Attach`.
52pub struct Connected {
53    reader: BufReader<ClientReadHalf>,
54    writer: mpsc::Sender<ClientFrame>,
55    writer_task: tokio::task::JoinHandle<()>,
56    pub welcome: Welcome,
57    /// Remembered for `reconnect`.
58    path: PathBuf,
59    hello: Hello,
60    buf: FrameBuf,
61}
62
63pub struct Pong {
64    pub pid: u32,
65    pub uptime_s: u64,
66    pub sessions: usize,
67}
68
69/// What `synaps auth state` got from the daemon.
70#[derive(Debug)]
71pub enum AuthStateReply {
72    Report(agent_core::auth::AuthStateReport),
73    /// The daemon predates `AuthState`: it answered the frame the way an
74    /// old daemon answers any frame it cannot decode. Its version and build
75    /// (empty when the daemon does not report one), from its `Welcome`.
76    DaemonPredates { daemon_version: String, daemon_build: String },
77}
78
79/// An old daemon's reply to `AuthState` (daemon/conn.rs `Read::Bad`: serde's
80/// unknown-variant error behind "malformed frame: "), and nothing else.
81pub fn predates_auth_state(message: &str) -> bool {
82    message.starts_with("malformed frame: unknown variant `auth_state`")
83}
84
85/// Reused line buffer for `read_frame`: a big `Attached`/`MessageHistory`
86/// frame grows it once; after any frame > 1 MiB it shrinks back to 64 KiB so
87/// the peak is not retained for the life of the connection (§2.1 #1).
88#[derive(Default)]
89pub struct FrameBuf {
90    line: Vec<u8>,
91    /// Length of the last decoded frame (bytes, without the newline).
92    last_frame_bytes: usize,
93}
94
95const FRAME_BUF_KEEP: usize = 64 * 1024;
96const FRAME_BUF_SHRINK_ABOVE: usize = 1024 * 1024;
97
98impl FrameBuf {
99    fn after_frame(&mut self, n: usize) {
100        self.last_frame_bytes = n;
101        if self.line.capacity() > FRAME_BUF_SHRINK_ABOVE {
102            self.line = Vec::new();
103            self.line.reserve(FRAME_BUF_KEEP);
104        }
105    }
106}
107
108async fn read_frame(reader: &mut BufReader<ClientReadHalf>, buf: &mut FrameBuf) -> Result<Option<DaemonFrame>, TransportError> {
109    // Cancel-safe: the TUI polls this inside `select!`, so a partial line
110    // stays in `buf.line` across a dropped future and is cleared only once
111    // a whole frame has been taken out of it.
112    loop {
113        // `take` bounds the read so an oversize frame cannot grow memory.
114        let limit = (MAX_FRAME_BYTES as u64 + 1).saturating_sub(buf.line.len() as u64);
115        let n = tokio::io::AsyncReadExt::take(&mut *reader, limit)
116            .read_until(b'\n', &mut buf.line)
117            .await?;
118        if n == 0 {
119            return Ok(None);
120        }
121        if buf.line.len() > MAX_FRAME_BYTES {
122            buf.line.clear();
123            return Err(TransportError::Protocol(frame_limit_msg()));
124        }
125        let decoded = {
126            let t = std::str::from_utf8(&buf.line)
127                .map_err(|e| TransportError::Protocol(format!("frame is not utf-8: {e}")))
128                .map(str::trim_end);
129            match t {
130                Ok("") => None,
131                Ok(t) => Some((decode_line::<DaemonFrame>(t).map(Some).map_err(TransportError::Protocol), t.len())),
132                Err(e) => Some((Err(e), 0)),
133            }
134        };
135        buf.line.clear();
136        let Some((decoded, len)) = decoded else { continue };
137        buf.after_frame(len);
138        return decoded;
139    }
140}
141
142fn spawn_writer(mut w: ClientWriteHalf) -> (mpsc::Sender<ClientFrame>, tokio::task::JoinHandle<()>) {
143    let (tx, mut rx) = mpsc::channel::<ClientFrame>(CMD_CHAN_CAP);
144    let task = tokio::spawn(async move {
145        while let Some(frame) = rx.recv().await {
146            let line = match encode_line(&frame) {
147                Ok(l) => l,
148                Err(e) => {
149                    tracing::warn!(frame = ?frame, "socket_transport: encode failed: {e}");
150                    continue;
151                }
152            };
153            if let Err(e) = w.write_all(line.as_bytes()).await {
154                tracing::debug!("socket_transport: write failed: {e}");
155                break;
156            }
157            if matches!(frame, ClientFrame::Bye) {
158                let _ = w.shutdown().await;
159                break;
160            }
161        }
162    });
163    (tx, task)
164}
165
166impl Connected {
167    /// Hello/Welcome only. `Refused{Version}` → `TransportError::Version`;
168    /// any other refusal → `Protocol`.
169    pub async fn connect(path: &Path, hello: Hello) -> Result<Self, TransportError> {
170        let stream = tokio::time::timeout(CONNECT_TIMEOUT, crate::ipc::connect(path))
171            .await
172            .map_err(|_| TransportError::Protocol("connect timed out".into()))??;
173        let (r, w) = crate::ipc::split_client(stream);
174        let mut reader = BufReader::new(r);
175        let (writer, writer_task) = spawn_writer(w);
176        let client_version = hello.protocol_version;
177        let hello_copy = hello.clone();
178        let mut buf = FrameBuf::default();
179        writer
180            .send(ClientFrame::Hello(hello))
181            .await
182            .map_err(|_| TransportError::Closed)?;
183        let frame = tokio::time::timeout(HANDSHAKE_TIMEOUT, read_frame(&mut reader, &mut buf))
184            .await
185            .map_err(|_| TransportError::Protocol("handshake timed out".into()))??;
186        match frame {
187            Some(DaemonFrame::Welcome(welcome)) => {
188                if welcome.protocol_version != client_version {
189                    return Err(TransportError::Version { client: client_version, daemon: welcome.protocol_version });
190                }
191                Ok(Self { reader, writer, writer_task, welcome, path: path.to_path_buf(), hello: hello_copy, buf })
192            }
193            Some(DaemonFrame::Refused { reason: RefuseReason::Version { daemon_version, .. }, .. }) => {
194                Err(TransportError::Version { client: client_version, daemon: daemon_version })
195            }
196            Some(DaemonFrame::Refused { reason, message }) => {
197                Err(TransportError::Protocol(format!("refused ({reason:?}): {message}")))
198            }
199            Some(other) => Err(TransportError::Protocol(format!("expected welcome, got {other:?}"))),
200            None => Err(TransportError::Closed),
201        }
202    }
203
204    async fn request(&mut self, frame: ClientFrame) -> Result<DaemonFrame, TransportError> {
205        self.writer.send(frame).await.map_err(|_| TransportError::Closed)?;
206        tokio::time::timeout(HANDSHAKE_TIMEOUT, read_frame(&mut self.reader, &mut self.buf))
207            .await
208            .map_err(|_| TransportError::Protocol("reply timed out".into()))??
209            .ok_or(TransportError::Closed)
210    }
211
212    pub async fn ping(&mut self) -> Result<Pong, TransportError> {
213        match self.request(ClientFrame::Ping).await? {
214            DaemonFrame::Pong { pid, uptime_s, sessions } => Ok(Pong { pid, uptime_s, sessions }),
215            other => Err(TransportError::Protocol(format!("expected pong, got {other:?}"))),
216        }
217    }
218
219    pub async fn sessions(&mut self) -> Result<Vec<SessionMeta>, TransportError> {
220        match self.request(ClientFrame::Sessions).await? {
221            DaemonFrame::SessionList { sessions } => Ok(sessions),
222            other => Err(TransportError::Protocol(format!("expected session list, got {other:?}"))),
223        }
224    }
225
226    /// `Purge` → the daemon runs `memstat::purge_arenas()`; replies `Pong`.
227    pub async fn purge(&mut self) -> Result<Pong, TransportError> {
228        match self.request(ClientFrame::Purge).await? {
229            DaemonFrame::Pong { pid, uptime_s, sessions } => Ok(Pong { pid, uptime_s, sessions }),
230            DaemonFrame::Error { message, .. } => Err(TransportError::Protocol(message)),
231            other => Err(TransportError::Protocol(format!("expected pong, got {other:?}"))),
232        }
233    }
234
235    /// `AuthState` → the daemon broker's in-memory account state. A daemon
236    /// that predates the frame is [`AuthStateReply::DaemonPredates`]; any
237    /// other error is returned as itself.
238    pub async fn auth_state(&mut self, query: agent_core::auth::AuthStateQuery) -> Result<AuthStateReply, TransportError> {
239        match self.request(ClientFrame::AuthState(query)).await? {
240            DaemonFrame::AuthState { report } => Ok(AuthStateReply::Report(report)),
241            DaemonFrame::Error { session_id: None, message } if predates_auth_state(&message) => {
242                Ok(AuthStateReply::DaemonPredates {
243                    daemon_version: self.welcome.daemon_version.clone(),
244                    daemon_build: self.welcome.daemon_build.clone(),
245                })
246            }
247            DaemonFrame::Error { message, .. } => Err(TransportError::Protocol(message)),
248            other => Err(TransportError::Protocol(format!("expected auth state, got {other:?}"))),
249        }
250    }
251
252    pub async fn shutdown(&mut self, force: bool) -> Result<(), TransportError> {
253        match self.request(ClientFrame::Shutdown { force }).await? {
254            DaemonFrame::Bye { .. } => Ok(()),
255            DaemonFrame::Error { message, .. } => Err(TransportError::Protocol(message)),
256            other => Err(TransportError::Protocol(format!("expected bye, got {other:?}"))),
257        }
258    }
259
260    /// `Close{id, force, attached}`: end one session with host authority.
261    /// `Ok(text)` = the session ended (the actor's `/close` reply, with how
262    /// to continue it). A refusal (work in flight, clients attached without
263    /// `attached`, daemon reloading) → `Refused(text)`;
264    /// no such session → `UnknownSession`; a daemon that predates `Close`
265    /// → `DaemonTooOld`. Waits up to [`CLOSE_TIMEOUT`]: the session saves
266    /// and runs its end hooks before the daemon answers.
267    pub async fn close(&mut self, id: &SessionId, force: bool, attached: bool) -> Result<String, TransportError> {
268        self.writer
269            .send(ClientFrame::Close { session_id: id.clone(), force, attached })
270            .await
271            .map_err(|_| TransportError::Closed)?;
272        let frame = tokio::time::timeout(CLOSE_TIMEOUT, read_frame(&mut self.reader, &mut self.buf))
273            .await
274            .map_err(|_| TransportError::Protocol("close reply timed out".into()))??;
275        close_reply(frame, &self.welcome.daemon_version)
276    }
277
278    /// `Pin{id, on}`: pin or unpin one session with host authority.
279    /// `Ok(on)` = the session's pin is now `on`. A refusal (daemon
280    /// reloading, session live in another process) → `Refused(text)`; no
281    /// such session → `UnknownSession`; a daemon that predates `Pin` →
282    /// `DaemonTooOld`.
283    pub async fn pin(&mut self, id: &SessionId, on: bool) -> Result<bool, TransportError> {
284        self.writer
285            .send(ClientFrame::Pin { session_id: id.clone(), on })
286            .await
287            .map_err(|_| TransportError::Closed)?;
288        let frame = tokio::time::timeout(PIN_TIMEOUT, read_frame(&mut self.reader, &mut self.buf))
289            .await
290            .map_err(|_| TransportError::Protocol("pin reply timed out".into()))??;
291        pin_reply(frame, &self.welcome.daemon_version)
292    }
293
294    /// `Rename{id, name, expect}`: name (`Some`) or unname (`None`) one
295    /// session with host authority, without attaching. `expect` is the
296    /// compare-and-set precondition (`Some(None)` = only if unnamed).
297    /// `Ok(Ok(renamed))` = done; `Ok(Err(failure))` = the daemon says why
298    /// not, or `Unknown` when no answer came (timeout, connection lost after
299    /// sending: it may still apply). No such session → `UnknownSession`; a
300    /// daemon that predates `Rename` → `DaemonTooOld` (never a hang: it
301    /// answers the unknown frame at once).
302    pub async fn rename(
303        &mut self,
304        id: &SessionId,
305        name: Option<&str>,
306        expect: Option<Option<String>>,
307    ) -> Result<Result<Renamed, RenameFailure>, TransportError> {
308        self.writer
309            .send(ClientFrame::Rename { session_id: id.clone(), name: name.map(str::to_string), expect })
310            .await
311            .map_err(|_| TransportError::Closed)?;
312        let unknown = |why: &str| {
313            Ok(Err(RenameFailure {
314                reason: crate::session::wire::RenameRefusal::Unknown,
315                message: format!("{why}; the rename may still apply. Re-list (`synaps daemon sessions`) before retrying"),
316                taken_by: None,
317                current_name: None,
318            }))
319        };
320        match tokio::time::timeout(RENAME_TIMEOUT, read_frame(&mut self.reader, &mut self.buf)).await {
321            Err(_) => unknown("no reply from the daemon in time"),
322            Ok(Ok(None)) | Ok(Err(TransportError::Closed)) => unknown("the daemon closed the connection before answering"),
323            Ok(frame) => rename_reply(frame?, &self.welcome.daemon_version),
324        }
325    }
326
327    /// `ReloadExtension{extension}`: stop that extension and start it again
328    /// from what is installed now. `Ok(Ok(report))` = reloaded;
329    /// `Ok(Err(failure))` = the daemon tried and says why not; a refusal
330    /// (daemon reloading) → `Refused(text)`; a daemon that predates the
331    /// frame → `DaemonTooOld`.
332    pub async fn reload_extension(
333        &mut self,
334        extension: &str,
335    ) -> Result<Result<ExtensionReloadReport, ExtensionReloadFailure>, TransportError> {
336        self.writer
337            .send(ClientFrame::ReloadExtension { extension: extension.to_string() })
338            .await
339            .map_err(|_| TransportError::Closed)?;
340        let frame = tokio::time::timeout(RELOAD_EXTENSION_TIMEOUT, read_frame(&mut self.reader, &mut self.buf))
341            .await
342            .map_err(|_| TransportError::Protocol("extension reload reply timed out".into()))??;
343        reload_extension_reply(frame, &self.welcome.daemon_version)
344    }
345
346    /// Say goodbye and drop the connection.
347    pub async fn bye(self) {
348        let _ = self.writer.send(ClientFrame::Bye).await;
349        let _ = tokio::time::timeout(Duration::from_millis(200), self.writer_task).await;
350    }
351}
352
353/// The daemon's answer to `Close`, as [`Connected::close`] reports it.
354fn close_reply(frame: Option<DaemonFrame>, daemon_version: &str) -> Result<String, TransportError> {
355    match frame {
356        Some(DaemonFrame::Closed { text, .. }) => Ok(text),
357        Some(DaemonFrame::Refused { reason: RefuseReason::UnknownSession, message }) => {
358            Err(TransportError::UnknownSession(message))
359        }
360        Some(DaemonFrame::Refused { message, .. }) => Err(TransportError::Refused(message)),
361        // An older daemon cannot decode the frame; it answers with serde's
362        // "unknown variant `close`" and keeps the connection open.
363        Some(DaemonFrame::Error { message, .. }) if message.contains("unknown variant `close`") => {
364            Err(TransportError::DaemonTooOld { daemon_version: daemon_version.to_string(), request: "daemon close" })
365        }
366        Some(DaemonFrame::Error { message, .. }) => Err(TransportError::Protocol(message)),
367        Some(other) => Err(TransportError::Protocol(format!("expected closed, got {other:?}"))),
368        None => Err(TransportError::Closed),
369    }
370}
371
372/// The daemon's answer to `Pin`, as [`Connected::pin`] reports it.
373fn pin_reply(frame: Option<DaemonFrame>, daemon_version: &str) -> Result<bool, TransportError> {
374    match frame {
375        Some(DaemonFrame::Pinned { on, .. }) => Ok(on),
376        Some(DaemonFrame::Refused { reason: RefuseReason::UnknownSession, message }) => {
377            Err(TransportError::UnknownSession(message))
378        }
379        Some(DaemonFrame::Refused { message, .. }) => Err(TransportError::Refused(message)),
380        // An older daemon cannot decode the frame; it answers with serde's
381        // "unknown variant `pin`" and keeps the connection open.
382        Some(DaemonFrame::Error { message, .. }) if message.contains("unknown variant `pin`") => {
383            Err(TransportError::DaemonTooOld { daemon_version: daemon_version.to_string(), request: "daemon pin" })
384        }
385        Some(DaemonFrame::Error { message, .. }) => Err(TransportError::Protocol(message)),
386        Some(other) => Err(TransportError::Protocol(format!("expected pinned, got {other:?}"))),
387        None => Err(TransportError::Closed),
388    }
389}
390
391/// A rename the daemon applied (`DaemonFrame::Renamed`).
392#[derive(Debug, Clone, PartialEq, Eq)]
393pub struct Renamed {
394    /// The session's name now (`None` = cleared).
395    pub name: Option<String>,
396    /// The session is not persisted: nothing was written.
397    pub in_memory_only: bool,
398}
399
400/// Why the daemon did not rename a session (`DaemonFrame::RenameFailed`).
401#[derive(Debug, Clone, PartialEq, Eq)]
402pub struct RenameFailure {
403    pub reason: crate::session::wire::RenameRefusal,
404    pub message: String,
405    /// Journal id of the saved session that already has the name (`Taken`).
406    pub taken_by: Option<String>,
407    /// The session's name when the `expect` precondition failed (`Named`).
408    pub current_name: Option<String>,
409}
410
411/// The daemon's answer to `Rename`, as [`Connected::rename`] reports it.
412fn rename_reply(
413    frame: Option<DaemonFrame>,
414    daemon_version: &str,
415) -> Result<Result<Renamed, RenameFailure>, TransportError> {
416    match frame {
417        Some(DaemonFrame::Renamed { name, in_memory_only, .. }) => Ok(Ok(Renamed { name, in_memory_only })),
418        Some(DaemonFrame::RenameFailed { reason, message, taken_by, current_name, .. }) => {
419            Ok(Err(RenameFailure { reason, message, taken_by, current_name }))
420        }
421        Some(DaemonFrame::Refused { reason: RefuseReason::UnknownSession, message }) => {
422            Err(TransportError::UnknownSession(message))
423        }
424        Some(DaemonFrame::Refused { message, .. }) => Err(TransportError::Refused(message)),
425        // An older daemon cannot decode the frame; it answers with serde's
426        // "unknown variant `rename`" and keeps the connection open.
427        Some(DaemonFrame::Error { message, .. }) if message.contains("unknown variant `rename`") => {
428            Err(TransportError::DaemonTooOld { daemon_version: daemon_version.to_string(), request: "daemon rename" })
429        }
430        Some(DaemonFrame::Error { message, .. }) => Err(TransportError::Protocol(message)),
431        Some(other) => Err(TransportError::Protocol(format!("expected renamed, got {other:?}"))),
432        None => Err(TransportError::Closed),
433    }
434}
435
436pub use crate::extensions::manager::ExtensionReloadReport;
437
438/// The daemon's reason an extension was not reloaded
439/// (`DaemonFrame::ExtensionReloadFailed`).
440#[derive(Debug, Clone, PartialEq, Eq)]
441pub struct ExtensionReloadFailure {
442    pub message: String,
443    /// The old instance was stopped before the new one failed to start: the
444    /// extension is not loaded now. `false` = the running one (if any) was
445    /// not touched.
446    pub unloaded: bool,
447}
448
449/// The daemon's answer to `ReloadExtension`, as
450/// [`Connected::reload_extension`] reports it.
451fn reload_extension_reply(
452    frame: Option<DaemonFrame>,
453    daemon_version: &str,
454) -> Result<Result<ExtensionReloadReport, ExtensionReloadFailure>, TransportError> {
455    match frame {
456        Some(DaemonFrame::ExtensionReloaded { report }) => Ok(Ok(report)),
457        Some(DaemonFrame::ExtensionReloadFailed { message, unloaded, .. }) => {
458            Ok(Err(ExtensionReloadFailure { message, unloaded }))
459        }
460        Some(DaemonFrame::Refused { message, .. }) => Err(TransportError::Refused(message)),
461        // An older daemon cannot decode the frame; it answers with serde's
462        // "unknown variant `reload_extension`" and keeps the connection open.
463        Some(DaemonFrame::Error { message, .. }) if message.contains("unknown variant `reload_extension`") => {
464            Err(TransportError::DaemonTooOld {
465                daemon_version: daemon_version.to_string(),
466                request: "daemon reload-extension",
467            })
468        }
469        Some(DaemonFrame::Error { message, .. }) => Err(TransportError::Protocol(message)),
470        Some(other) => Err(TransportError::Protocol(format!("expected extension_reloaded, got {other:?}"))),
471        None => Err(TransportError::Closed),
472    }
473}
474
475pub struct SocketTransport {
476    reader: BufReader<ClientReadHalf>,
477    writer: mpsc::Sender<ClientFrame>,
478    _writer_task: tokio::task::JoinHandle<()>,
479    session: SessionId,
480    meta: SessionMeta,
481    client: ClientId,
482    view: Arc<arc_swap::ArcSwap<RuntimeView>>,
483    pub welcome: Welcome,
484    mode: AttachMode,
485    input_owner: Option<ClientId>,
486    roster: Roster,
487    client_input: ClientInput,
488    ended: bool,
489    path: PathBuf,
490    hello: Hello,
491    /// `Reloading` seen; the next EOF is a reload, not a death.
492    reload_pending: Option<u64>,
493    /// Why `next_event` returned `None` (`Reloading{generation}` after a
494    /// reload announcement) — the TUI branches to `reconnect` on it.
495    last_error: Option<TransportError>,
496    /// Local `api_messages` mirror (`HistoryMode::Full` only): seeded by
497    /// `Attached`, updated by `Stream(MessageHistory)` and
498    /// `QueryResult{DIGEST_RESYNC_QUERY_ID}`. `Conversation` arrives as a
499    /// digest; the mirror fills it in. Empty forever in `Digest` mode.
500    messages: Vec<crate::SharedMessage>,
501    /// Digest awaiting a `Messages` re-query (hash miss; Full mode only).
502    pending_digest: Option<ConversationDigest>,
503    /// What we asked for in `Hello` (phase 4 §2.3).
504    history: HistoryMode,
505    buf: FrameBuf,
506}
507
508impl SocketTransport {
509    /// Hello/Welcome only (50 ms connect + 2 s handshake budgets).
510    pub async fn connect(path: &Path, hello: Hello) -> Result<Connected, TransportError> {
511        Connected::connect(path, hello).await
512    }
513
514    /// Attach (existing or create) on a handshaken connection. `AttachRefused`
515    /// and `DaemonFrame::Error` alike → `TransportError::Refused`. Waits
516    /// `ATTACH_TIMEOUT_PARKED` when the daemon lists the session as `Parked`.
517    pub async fn attach(mut conn: Connected, attach: Attach) -> Result<(Self, AttachSnapshot), TransportError> {
518        let (mode, parked) = match &attach {
519            Attach::Existing { session_id, mode } => (
520                *mode,
521                conn.welcome
522                    .sessions
523                    .iter()
524                    .any(|m| &m.id == session_id && m.lifecycle == SessionLifecycle::Parked),
525            ),
526            Attach::Create { mode, .. } => (*mode, false),
527        };
528        conn.writer
529            .send(ClientFrame::Attach(attach))
530            .await
531            .map_err(|_| TransportError::Closed)?;
532        let budget = if parked { ATTACH_TIMEOUT_PARKED } else { ATTACH_TIMEOUT };
533        let deadline = tokio::time::Instant::now() + budget;
534        let attached = loop {
535            let frame = tokio::time::timeout_at(deadline, read_frame(&mut conn.reader, &mut conn.buf))
536                .await
537                .map_err(|_| TransportError::Protocol("attach timed out".into()))??;
538            match frame {
539                Some(DaemonFrame::Attached(a)) => break a,
540                Some(DaemonFrame::Error { message, .. }) => return Err(TransportError::Refused(message)),
541                Some(DaemonFrame::Event(w)) => match w.event {
542                    WireSessionEvent::AttachRefused { message } => return Err(TransportError::Refused(message)),
543                    // Notices/lifecycle chatter may precede `Attached` (unpark).
544                    _ => continue,
545                },
546                Some(DaemonFrame::Bye { .. }) | None => return Err(TransportError::Closed),
547                Some(other) => return Err(TransportError::Protocol(format!("expected attached, got {other:?}"))),
548            }
549        };
550        let (client, snapshot) = attached.into_snapshot();
551        let meta = snapshot.meta.clone();
552        let view = Arc::new(arc_swap::ArcSwap::from_pointee(snapshot.view.clone()));
553        let history = conn.hello.client.history;
554        let messages = match history {
555            HistoryMode::Full => snapshot.conversation.api_messages.clone(),
556            HistoryMode::Digest => Vec::new(),
557        };
558        Ok((
559            Self {
560                reader: conn.reader,
561                writer: conn.writer,
562                _writer_task: conn.writer_task,
563                session: meta.id.clone(),
564                meta,
565                client,
566                view,
567                welcome: conn.welcome,
568                mode,
569                input_owner: snapshot.input_owner,
570                roster: Roster::from_snapshot(&snapshot),
571                client_input: snapshot.client_input,
572                ended: false,
573                path: conn.path,
574                hello: conn.hello,
575                reload_pending: None,
576                last_error: None,
577                messages,
578                pending_digest: None,
579                history,
580                buf: conn.buf,
581            },
582            snapshot,
583        ))
584    }
585
586    /// Why the last `next_event` returned `None` (if it was not a plain end).
587    pub fn last_error(&self) -> Option<&TransportError> {
588        self.last_error.as_ref()
589    }
590
591    /// `true` if the daemon announced a reload before EOF (graceful); `false`
592    /// means a crash/SIGKILL (unexpected).
593    pub fn is_reload_pending(&self) -> bool {
594        self.reload_pending.is_some()
595    }
596
597    /// The pid the daemon reported in `Welcome`.
598    pub fn daemon_pid(&self) -> u32 {
599        self.welcome.pid
600    }
601
602    /// The reconnect budget from `SYNAPS_TUI_ATTACH_RECONNECT_SECS` (default 60).
603    pub fn reconnect_budget() -> Duration {
604        std::env::var("SYNAPS_TUI_ATTACH_RECONNECT_SECS")
605            .ok()
606            .and_then(|s| s.parse().ok())
607            .map(Duration::from_secs)
608            .unwrap_or(Duration::from_secs(60))
609    }
610
611    /// One attempt at reconnecting (Hello + Attach). On success replaces
612    /// `self` and returns the snapshot. On `Refused` / `Version` the error is
613    /// terminal (caller should stop retrying). Other errors are transient.
614    pub async fn reconnect_once(&mut self, mode: AttachMode) -> Result<AttachSnapshot, TransportError> {
615        let was_owner = self.input_owner == Some(self.client);
616        let generation = self.reload_pending.unwrap_or(self.welcome.generation);
617        let mut hello = self.hello.clone();
618        hello.reconnect_of = Some(ClientReconnect {
619            previous_client: self.client,
620            session_id: self.session.clone(),
621            was_owner,
622            generation,
623        });
624        let mode = if was_owner { AttachMode::Takeover } else { mode };
625        match Connected::connect(&self.path, hello).await {
626            Ok(conn) => {
627                match Self::attach(conn, Attach::Existing { session_id: self.session.clone(), mode }).await {
628                    Ok((t, snap)) => {
629                        *self = t;
630                        Ok(snap)
631                    }
632                    Err(e) => Err(e),
633                }
634            }
635            Err(e) => Err(e),
636        }
637    }
638
639    // ── control fast path helpers (fresh connection each) ──
640
641    pub async fn ping(path: &Path) -> Result<Pong, TransportError> {
642        let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
643        let p = c.ping().await;
644        c.bye().await;
645        p
646    }
647
648    pub async fn sessions(path: &Path) -> Result<Vec<SessionMeta>, TransportError> {
649        let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
650        let s = c.sessions().await;
651        c.bye().await;
652        s
653    }
654
655    pub async fn shutdown(path: &Path, force: bool) -> Result<(), TransportError> {
656        let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
657        c.shutdown(force).await
658    }
659
660    pub async fn purge(path: &Path) -> Result<Pong, TransportError> {
661        let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
662        let p = c.purge().await;
663        c.bye().await;
664        p
665    }
666
667    /// `Close{id, force, attached}` on a fresh control connection (see
668    /// [`Connected::close`]).
669    pub async fn close(path: &Path, id: &SessionId, force: bool, attached: bool) -> Result<String, TransportError> {
670        let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
671        let r = c.close(id, force, attached).await;
672        c.bye().await;
673        r
674    }
675
676    /// `Pin{id, on}` on a fresh control connection (see [`Connected::pin`]).
677    pub async fn pin(path: &Path, id: &SessionId, on: bool) -> Result<bool, TransportError> {
678        let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
679        let r = c.pin(id, on).await;
680        c.bye().await;
681        r
682    }
683
684    /// `Rename{id, name}` on a fresh control connection (see
685    /// [`Connected::rename`]).
686    pub async fn rename(
687        path: &Path,
688        id: &SessionId,
689        name: Option<&str>,
690        expect: Option<Option<String>>,
691    ) -> Result<Result<Renamed, RenameFailure>, TransportError> {
692        let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
693        let r = c.rename(id, name, expect).await;
694        c.bye().await;
695        r
696    }
697
698    /// `AuthState` on a control connection (see [`Connected::auth_state`]).
699    pub async fn auth_state(path: &Path, query: agent_core::auth::AuthStateQuery) -> Result<AuthStateReply, TransportError> {
700        let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
701        let reply = c.auth_state(query).await;
702        c.bye().await;
703        reply
704    }
705
706    /// `Reload{..}` on a control connection. `Ok(generation)` once the daemon
707    /// answered `Bye{Reloading{generation}}` (it is about to exec);
708    /// `Refused{ReloadRefused}` / `Error` → `Err`.
709    pub async fn reload(path: &Path, now: bool, drain_secs: Option<u64>, exe: Option<PathBuf>) -> Result<u64, TransportError> {
710        let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
711        c.writer
712            .send(ClientFrame::Reload { now, drain_secs, exe })
713            .await
714            .map_err(|_| TransportError::Closed)?;
715        // Drain + checkpoint can take a while: wait up to drain + 60 s.
716        let budget = Duration::from_secs(drain_secs.unwrap_or(30) + 60);
717        let frame = tokio::time::timeout(budget, read_frame(&mut c.reader, &mut c.buf))
718            .await
719            .map_err(|_| TransportError::Protocol("reload reply timed out".into()))??;
720        match frame {
721            Some(DaemonFrame::Bye { reason: Some(ByeReason::Reloading { generation, .. }) }) => Ok(generation),
722            Some(DaemonFrame::Bye { .. }) => Err(TransportError::Protocol("daemon said bye without a reload reason".into())),
723            Some(DaemonFrame::Refused { reason: RefuseReason::ReloadRefused { why }, .. }) => Err(TransportError::Refused(why)),
724            Some(DaemonFrame::Refused { message, .. }) | Some(DaemonFrame::Error { message, .. }) => Err(TransportError::Refused(message)),
725            Some(other) => Err(TransportError::Protocol(format!("expected bye, got {other:?}"))),
726            None => Err(TransportError::Closed),
727        }
728    }
729
730    /// Detach cleanly: `Detach` + `Bye`. The turn keeps running in the daemon.
731    pub async fn detach(&self) {
732        let _ = self
733            .writer
734            .send(ClientFrame::Cmd { session_id: self.session.clone(), cmd: SessionCommand::Detach { client: self.client } })
735            .await;
736        let _ = self.writer.send(ClientFrame::Bye).await;
737    }
738
739    fn notice(&self, text: String) -> Envelope {
740        Envelope { session_id: self.session.clone(), seq: u64::MAX, ts: chrono::Utc::now(), event: SessionEventWire::SystemNotice(text) }
741    }
742
743    /// The client's view of `api_messages` (kept current by the digest
744    /// protocol in `Full` mode; always empty in `Digest` mode).
745    pub fn messages(&self) -> &[crate::SharedMessage] {
746        &self.messages
747    }
748
749    /// Length in bytes of the last frame decoded on this connection
750    /// (the `Attached` frame right after `attach`).
751    pub fn last_frame_bytes(&self) -> usize {
752        self.buf.last_frame_bytes
753    }
754
755    pub fn history_mode(&self) -> HistoryMode {
756        self.history
757    }
758
759    /// Turn a wire envelope into the in-process one, keeping the message
760    /// mirror honest. `None` = swallowed (reserved query id, or a digest that
761    /// needs a re-query first — the `Conversation` is re-emitted once the
762    /// `Messages` result lands).
763    fn absorb(&mut self, w: WireEnvelope) -> Option<Envelope> {
764        if self.history == HistoryMode::Digest {
765            return self.absorb_digest(w);
766        }
767        match w.event {
768            WireSessionEvent::Conversation { digest } => {
769                if digest.matches(&self.messages) {
770                    let event = SessionEventWire::Conversation(digest.into_snapshot(self.messages.clone()));
771                    return Some(Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event });
772                }
773                if digest.messages_len == 0 {
774                    self.messages.clear();
775                    let event = SessionEventWire::Conversation(digest.into_snapshot(Vec::new()));
776                    return Some(Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event });
777                }
778                // Mirror drifted (compaction, abort repair, flushed events): re-fetch.
779                let first = self.pending_digest.is_none();
780                self.pending_digest = Some(digest);
781                if first {
782                    let _ = self.writer.try_send(ClientFrame::Cmd {
783                        session_id: self.session.clone(),
784                        cmd: SessionCommand::Query { id: DIGEST_RESYNC_QUERY_ID, query: SessionQuery::Messages },
785                    });
786                }
787                None
788            }
789            WireSessionEvent::QueryResult { id, value } if id == DIGEST_RESYNC_QUERY_ID => {
790                if let Ok(msgs) = serde_json::from_value::<Vec<serde_json::Value>>(value) {
791                    self.messages = msgs.into_iter().map(Arc::new).collect();
792                }
793                let digest = self.pending_digest.take()?;
794                if !digest.matches(&self.messages) {
795                    tracing::debug!("socket_transport: messages re-query still disagrees with digest; using fetched history");
796                }
797                let event = SessionEventWire::Conversation(digest.into_snapshot(self.messages.clone()));
798                Some(Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event })
799            }
800            WireSessionEvent::QueryResult { id, .. } if id >= RESERVED_QUERY_ID_BASE => None,
801            event => {
802                let env = Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event: event.into() };
803                match &env.event {
804                    SessionEventWire::Stream(crate::StreamEvent::Session(crate::SessionEvent::MessageHistory(m))) => {
805                        self.messages = m.clone();
806                    }
807                    SessionEventWire::Attached { snapshot, .. } => {
808                        self.messages = snapshot.conversation.api_messages.clone();
809                    }
810                    _ => {}
811                }
812                Some(env)
813            }
814        }
815    }
816}
817
818impl SocketTransport {
819    /// `HistoryMode::Digest`: no mirror, no `matches()` re-serialisation, no
820    /// `Query{Messages}`. `Conversation` is passed through with an empty
821    /// `api_messages` (`messages_len` from the digest); a stray
822    /// `MessageHistory` is dropped (the daemon should not send one).
823    fn absorb_digest(&mut self, w: WireEnvelope) -> Option<Envelope> {
824        match w.event {
825            WireSessionEvent::Conversation { digest } => {
826                let event = SessionEventWire::Conversation(digest.into_snapshot(Vec::new()));
827                Some(Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event })
828            }
829            WireSessionEvent::QueryResult { id, .. } if id >= RESERVED_QUERY_ID_BASE => None,
830            event => {
831                let env = Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event: event.into() };
832                if matches!(
833                    env.event,
834                    SessionEventWire::Stream(crate::StreamEvent::Session(crate::SessionEvent::MessageHistory(_)))
835                ) {
836                    return None;
837                }
838                Some(env)
839            }
840        }
841    }
842}
843
844#[async_trait::async_trait]
845impl ClientTransport for SocketTransport {
846    fn session_id(&self) -> &SessionId {
847        &self.session
848    }
849
850    fn meta(&self) -> &SessionMeta {
851        &self.meta
852    }
853
854    async fn send(&self, cmd: SessionCommand) -> Result<(), TransportError> {
855        match self.writer.try_send(ClientFrame::Cmd { session_id: self.session.clone(), cmd }) {
856            Ok(()) => Ok(()),
857            Err(mpsc::error::TrySendError::Full(_)) => Err(TransportError::Backpressure),
858            Err(mpsc::error::TrySendError::Closed(_)) => Err(TransportError::Closed),
859        }
860    }
861
862    /// The daemon's conn stamps `from` with this connection's client id, so
863    /// on the wire `send` and `send_from_self` are the same frame.
864    async fn send_from_self(&self, cmd: SessionCommand) -> Result<(), TransportError> {
865        self.send(cmd).await
866    }
867
868    async fn next_event(&mut self) -> Option<Envelope> {
869        if self.ended {
870            return None;
871        }
872        loop {
873            match read_frame(&mut self.reader, &mut self.buf).await {
874                Ok(Some(DaemonFrame::Event(w))) => {
875                    let Some(env) = self.absorb(w) else { continue };
876                    match &env.event {
877                        SessionEventWire::SettingChanged(applied) => {
878                            self.view.store(Arc::new(applied.view.clone()));
879                        }
880                        SessionEventWire::Ended { .. } => self.ended = true,
881                        SessionEventWire::InputOwnerChanged { to, .. } => self.input_owner = *to,
882                        SessionEventWire::Reloading { generation, .. } => {
883                            self.reload_pending = Some(*generation);
884                        }
885                        other => self.roster.apply(other),
886                    }
887                    return Some(env);
888                }
889                Ok(Some(DaemonFrame::Error { message, .. })) => return Some(self.notice(message)),
890                Ok(Some(DaemonFrame::Bye { reason })) => {
891                    if let Some(ByeReason::Reloading { generation, .. }) = reason {
892                        self.reload_pending = Some(generation);
893                    }
894                    self.ended = true;
895                    if let Some(generation) = self.reload_pending {
896                        self.last_error = Some(TransportError::Reloading { generation });
897                    }
898                    return None;
899                }
900                Ok(None) => {
901                    self.ended = true;
902                    if let Some(generation) = self.reload_pending {
903                        self.last_error = Some(TransportError::Reloading { generation });
904                    }
905                    return None;
906                }
907                Ok(Some(other)) => {
908                    tracing::debug!("socket_transport: unexpected frame after attach: {other:?}");
909                    continue;
910                }
911                Err(e) => {
912                    tracing::debug!("socket_transport: read error: {e}");
913                    self.ended = true;
914                    return None;
915                }
916            }
917        }
918    }
919
920    fn view(&self) -> Arc<RuntimeView> {
921        Arc::clone(&self.view.load())
922    }
923
924    fn client_id(&self) -> ClientId {
925        self.client
926    }
927
928    fn mode(&self) -> AttachMode {
929        self.mode
930    }
931
932    fn input_owner(&self) -> Option<ClientId> {
933        self.input_owner
934    }
935
936    fn roster(&self) -> Roster {
937        self.roster.clone()
938    }
939
940    fn client_input(&self) -> ClientInput {
941        self.client_input
942    }
943
944    /// C3: after `Reloading`/EOF — backoff 100 ms ×2 → cap 5 s, total ≤
945    /// `SYNAPS_TUI_ATTACH_RECONNECT_SECS` (60); `Hello{reconnect_of}` →
946    /// `Attach::Existing{alias-resolved id, mode}` where `mode = Takeover`
947    /// iff this client was the input owner (§2.7: two reconnecting mirrors
948    /// cannot both take over). On success `self` IS the new connection; the
949    /// returned snapshot is a full re-mirror.
950    async fn reconnect(&mut self, mode: AttachMode) -> Result<AttachSnapshot, TransportError> {
951        let was_owner = self.input_owner == Some(self.client);
952        let generation = self.reload_pending.unwrap_or(self.welcome.generation);
953        let mut hello = self.hello.clone();
954        hello.reconnect_of = Some(ClientReconnect {
955            previous_client: self.client,
956            session_id: self.session.clone(),
957            was_owner,
958            generation,
959        });
960        let mode = if was_owner { AttachMode::Takeover } else { mode };
961        let total = std::env::var("SYNAPS_TUI_ATTACH_RECONNECT_SECS")
962            .ok()
963            .and_then(|s| s.parse().ok())
964            .map(Duration::from_secs)
965            .unwrap_or(Duration::from_secs(60));
966        let deadline = tokio::time::Instant::now() + total;
967        let mut backoff = Duration::from_millis(100);
968        let mut last;
969        loop {
970            match Connected::connect(&self.path, hello.clone()).await {
971                Ok(conn) => {
972                    match Self::attach(conn, Attach::Existing { session_id: self.session.clone(), mode }).await {
973                        Ok((t, snap)) => {
974                            *self = t;
975                            return Ok(snap);
976                        }
977                        Err(TransportError::Refused(m)) => return Err(TransportError::Refused(m)),
978                        Err(e) => last = e,
979                    }
980                }
981                Err(TransportError::Version { client, daemon }) => {
982                    return Err(TransportError::Version { client, daemon })
983                }
984                Err(e) => last = e,
985            }
986            if tokio::time::Instant::now() + backoff > deadline {
987                return Err(last);
988            }
989            tokio::time::sleep(backoff).await;
990            backoff = (backoff * 2).min(Duration::from_secs(5));
991        }
992    }
993
994    async fn reconnect_once(&mut self, mode: AttachMode) -> Result<AttachSnapshot, TransportError> {
995        // Delegate to the inherent method (same name, not trait-dispatched).
996        SocketTransport::reconnect_once(self, mode).await
997    }
998
999    fn is_reload_pending(&self) -> bool {
1000        SocketTransport::is_reload_pending(self)
1001    }
1002
1003    fn daemon_pid(&self) -> u32 {
1004        SocketTransport::daemon_pid(self)
1005    }
1006
1007    fn daemon_binary(&self) -> Option<crate::session::transport::BinaryId> {
1008        Some(crate::session::transport::BinaryId::of(&self.welcome))
1009    }
1010
1011    fn socket_path(&self) -> Option<&Path> {
1012        Some(&self.path)
1013    }
1014}
1015
1016#[cfg(test)]
1017mod tests {
1018    use super::*;
1019    use crate::ipc::Listener;
1020    use crate::{LlmEvent, StreamEvent};
1021
1022    /// A hand-rolled daemon: Welcome, answer Ping, Attach → Attached, then
1023    /// echo every Cmd::Submit as an Event stream.
1024    async fn fake_daemon(mut listener: Listener, protocol_version: u32) {
1025        let stream = listener.accept().await.unwrap();
1026        let (r, mut w) = crate::ipc::split_server(stream);
1027        let mut reader = BufReader::new(r);
1028        let mut line = String::new();
1029        let mut seq = 0u64;
1030        let sid = SessionId::from("fake-1");
1031        loop {
1032            line.clear();
1033            if reader.read_line(&mut line).await.unwrap() == 0 {
1034                break;
1035            }
1036            let frame: ClientFrame = decode_line(line.trim_end()).unwrap();
1037            let reply = match frame {
1038                ClientFrame::Hello(h) => {
1039                    if h.protocol_version != protocol_version {
1040                        let f = DaemonFrame::Refused {
1041                            reason: RefuseReason::Version { daemon_version: protocol_version, min: protocol_version, max: protocol_version },
1042                            message: "version".into(),
1043                        };
1044                        w.write_all(encode_line(&f).unwrap().as_bytes()).await.unwrap();
1045                        break;
1046                    }
1047                    DaemonFrame::Welcome(Welcome {
1048                        protocol_version,
1049                        daemon_version: "t".into(),
1050                        pid: 1,
1051                        profile: None,
1052                        sessions: vec![],
1053                        progressive_tool_disclosure: true,
1054                        generation: 1,
1055                        daemon_build: String::new(),
1056                    })
1057                }
1058                ClientFrame::Ping => DaemonFrame::Pong { pid: 1, uptime_s: 0, sessions: 0 },
1059                ClientFrame::Attach(_) => DaemonFrame::Attached(AttachedWire::new(
1060                    ClientId(7),
1061                    AttachSnapshot {
1062                        meta: crate::session::handle::echo::meta_for(&sid),
1063                        view: crate::session::handle::echo::view_for(),
1064                        conversation: ConversationSnapshot::default(),
1065                        streaming: false,
1066                        replay: vec![],
1067                        pending_prompts: vec![],
1068                        clients: vec![],
1069                        roster: Vec::new(),
1070                        client_input: ClientInput::wire_default(),
1071                        input_owner: Some(ClientId(7)),
1072                        display_tail: None,
1073                    },
1074                )),
1075                ClientFrame::Cmd { cmd: SessionCommand::Submit { text, .. }, .. } => {
1076                    seq += 1;
1077                    DaemonFrame::Event(WireEnvelope {
1078                        session_id: sid.clone(),
1079                        seq,
1080                        ts: chrono::Utc::now(),
1081                        event: SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::Text(text))).into(),
1082                    })
1083                }
1084                ClientFrame::Cmd { cmd: SessionCommand::Set { id, .. }, .. } => {
1085                    seq += 1;
1086                    let mut view = crate::session::handle::echo::view_for();
1087                    view.model = "changed".into();
1088                    DaemonFrame::Event(WireEnvelope {
1089                        session_id: sid.clone(),
1090                        seq,
1091                        ts: chrono::Utc::now(),
1092                        event: SessionEventWire::SettingChanged(SettingApplied {
1093                            id,
1094                            setting: "model".into(),
1095                            ok: true,
1096                            message: None,
1097                            view,
1098                            clamp: None,
1099                        })
1100                        .into(),
1101                    })
1102                }
1103                // Checkpoint = "the daemon is about to reload": Reloading event, then Bye{Reloading}.
1104                ClientFrame::Cmd { cmd: SessionCommand::Checkpoint { .. }, .. } => {
1105                    seq += 1;
1106                    let ev = DaemonFrame::Event(WireEnvelope {
1107                        session_id: sid.clone(),
1108                        seq,
1109                        ts: chrono::Utc::now(),
1110                        event: WireSessionEvent::Reloading { generation: 2, retry_after_ms: 500 },
1111                    });
1112                    w.write_all(encode_line(&ev).unwrap().as_bytes()).await.unwrap();
1113                    let bye = DaemonFrame::Bye { reason: Some(ByeReason::Reloading { generation: 2, retry_after_ms: 500 }) };
1114                    w.write_all(encode_line(&bye).unwrap().as_bytes()).await.unwrap();
1115                    break;
1116                }
1117                // KeepWarm = ownership change notice.
1118                ClientFrame::Cmd { cmd: SessionCommand::KeepWarm { .. }, .. } => {
1119                    seq += 1;
1120                    DaemonFrame::Event(WireEnvelope {
1121                        session_id: sid.clone(),
1122                        seq,
1123                        ts: chrono::Utc::now(),
1124                        event: WireSessionEvent::InputOwnerChanged {
1125                            from: Some(ClientId(7)),
1126                            to: Some(ClientId(9)),
1127                            reason: OwnerChangeReason::Takeover,
1128                        },
1129                    })
1130                }
1131                // Steer = "the daemon's history drifted": digest for [user:text] without a MessageHistory.
1132                ClientFrame::Cmd { cmd: SessionCommand::Steer { text }, .. } => {
1133                    seq += 1;
1134                    let snap = ConversationSnapshot {
1135                        api_messages: vec![Arc::new(serde_json::json!({"role":"user","content":text}))],
1136                        cost: 1.5,
1137                        ..Default::default()
1138                    };
1139                    DaemonFrame::Event(WireEnvelope {
1140                        session_id: sid.clone(),
1141                        seq,
1142                        ts: chrono::Utc::now(),
1143                        event: SessionEventWire::Conversation(snap).into(),
1144                    })
1145                }
1146                ClientFrame::Cmd { cmd: SessionCommand::Query { id, query: SessionQuery::Messages }, .. } => {
1147                    seq += 1;
1148                    DaemonFrame::Event(WireEnvelope {
1149                        session_id: sid.clone(),
1150                        seq,
1151                        ts: chrono::Utc::now(),
1152                        event: WireSessionEvent::QueryResult {
1153                            id,
1154                            value: serde_json::json!([{"role":"user","content":"drifted"}]),
1155                        },
1156                    })
1157                }
1158                ClientFrame::Bye => {
1159                    let _ = w.write_all(encode_line(&DaemonFrame::Bye { reason: None }).unwrap().as_bytes()).await;
1160                    break;
1161                }
1162                ClientFrame::Cmd { cmd: SessionCommand::Detach { .. }, .. } => continue,
1163                _ => DaemonFrame::Error { session_id: None, message: "nope".into() },
1164            };
1165            let bytes = encode_line(&reply).unwrap();
1166            // `split:` Submit text: the frame arrives in two writes 50 ms
1167            // apart (a frame split across reads, as a large one can be).
1168            if bytes.contains("split:") {
1169                let (a, b) = bytes.as_bytes().split_at(bytes.len() / 2);
1170                w.write_all(a).await.unwrap();
1171                w.flush().await.unwrap();
1172                tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1173                w.write_all(b).await.unwrap();
1174                continue;
1175            }
1176            w.write_all(bytes.as_bytes()).await.unwrap();
1177        }
1178    }
1179
1180    fn sock() -> (tempfile::TempDir, std::path::PathBuf) {
1181        let d = tempfile::tempdir().unwrap();
1182        let p = d.path().join("d.sock");
1183        (d, p)
1184    }
1185
1186    /// A daemon built before `AuthState` decodes the frame into a
1187    /// `ClientFrame` without that variant, fails, and answers
1188    /// `format!("malformed frame: {e}")` (daemon/conn.rs `Read::Bad`). That
1189    /// reply, and no other error, means "the daemon predates auth state".
1190    #[test]
1191    fn predates_auth_state_matches_an_old_daemons_decode_error_and_nothing_else() {
1192        #[derive(serde::Deserialize)]
1193        #[serde(tag = "type", rename_all = "snake_case")]
1194        #[allow(dead_code, clippy::large_enum_variant)]
1195        enum OldClientFrame {
1196            Hello(Hello),
1197            Ping,
1198            Sessions,
1199            Shutdown {
1200                #[serde(default)]
1201                force: bool,
1202            },
1203            Purge,
1204            Bye,
1205        }
1206        for query in [
1207            agent_core::auth::AuthStateQuery::default(),
1208            agent_core::auth::AuthStateQuery { provider: Some("anthropic".into()), account: Some("b".into()), clear: true },
1209        ] {
1210            let line = encode_line(&ClientFrame::AuthState(query)).unwrap();
1211            let err = decode_line::<OldClientFrame>(line.trim_end()).err().expect("an old daemon cannot decode it");
1212            let reply = format!("malformed frame: {err}");
1213            assert!(predates_auth_state(&reply), "{reply}");
1214        }
1215        for other in [
1216            "auth state: unknown account `zzz`",
1217            "malformed frame: expected value at line 1 column 1",
1218            "malformed frame: unknown variant `auth_status`, expected one of `hello`, `ping`",
1219            "malformed frame: missing field `clear`",
1220            "control frames are not accepted on an attached connection",
1221            "duplicate hello",
1222            "",
1223        ] {
1224            assert!(!predates_auth_state(other), "{other:?}");
1225        }
1226    }
1227
1228    /// The TUI polls `next_event()` inside `tokio::select!` next to a 16 ms
1229    /// tick, so the future is dropped whenever the tick wins. A frame split
1230    /// across reads must survive that cancellation.
1231    #[tokio::test]
1232    async fn next_event_survives_cancellation_mid_frame() {
1233        let (_d, path) = sock();
1234        let l = Listener::bind(&path).unwrap();
1235        let srv = tokio::spawn(fake_daemon(l, PROTOCOL_VERSION));
1236        let conn = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.unwrap();
1237        let (mut t, _) = SocketTransport::attach(conn, Attach::Existing { session_id: "fake-1".into(), mode: AttachMode::Mirror })
1238            .await
1239            .unwrap();
1240        t.send(SessionCommand::Submit { text: "split:hello".into(), attachments: vec![] }).await.unwrap();
1241        // First half is in; the tick wins and drops the read.
1242        tokio::select! {
1243            e = t.next_event() => panic!("frame cannot be complete yet: {e:?}"),
1244            _ = tokio::time::sleep(std::time::Duration::from_millis(20)) => {}
1245        }
1246        let e = tokio::time::timeout(std::time::Duration::from_secs(2), t.next_event())
1247            .await
1248            .expect("no event");
1249        match e.map(|e| e.event) {
1250            Some(SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::Text(s)))) => assert_eq!(s, "split:hello"),
1251            other => panic!("frame lost to cancellation: {other:?} (last_error={:?})", t.last_error()),
1252        }
1253        t.detach().await;
1254        let _ = srv.await;
1255    }
1256
1257    #[tokio::test]
1258    async fn socket_transport_handshake_attach_pump() {
1259        let (_d, path) = sock();
1260        let l = Listener::bind(&path).unwrap();
1261        let srv = tokio::spawn(fake_daemon(l, PROTOCOL_VERSION));
1262
1263        let mut conn = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.unwrap();
1264        assert_eq!(conn.welcome.protocol_version, PROTOCOL_VERSION);
1265        assert_eq!(conn.ping().await.unwrap().pid, 1);
1266        let (mut t, snap) = SocketTransport::attach(conn, Attach::Existing { session_id: "fake-1".into(), mode: AttachMode::Mirror })
1267            .await
1268            .unwrap();
1269        assert_eq!(t.client_id(), ClientId(7));
1270        assert_eq!(t.mode(), AttachMode::Mirror);
1271        assert_eq!(t.input_owner(), Some(ClientId(7)));
1272        assert_eq!(snap.meta.id.as_str(), "fake-1");
1273        assert_eq!(t.session_id().as_str(), "fake-1");
1274        assert_eq!(t.view().model, "echo");
1275
1276        t.send(SessionCommand::Submit { text: "hello".into(), attachments: vec![] }).await.unwrap();
1277        let e = t.next_event().await.unwrap();
1278        match e.event {
1279            SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::Text(s))) => assert_eq!(s, "hello"),
1280            other => panic!("{other:?}"),
1281        }
1282        t.send_from_self(SessionCommand::Set { id: 11, setting: SessionSetting::Model { model: "changed".into() } })
1283            .await
1284            .unwrap();
1285        let e = t.next_event().await.unwrap();
1286        assert!(matches!(e.event, SessionEventWire::SettingChanged(SettingApplied { id: 11, .. })));
1287        assert_eq!(t.view().model, "changed");
1288        t.send(SessionCommand::KeepWarm { on: true }).await.unwrap();
1289        let e = t.next_event().await.unwrap();
1290        assert!(matches!(e.event, SessionEventWire::InputOwnerChanged { .. }));
1291        assert_eq!(t.input_owner(), Some(ClientId(9)));
1292
1293        // Error → SystemNotice; Bye → None.
1294        t.send(SessionCommand::Save).await.unwrap();
1295        assert!(matches!(t.next_event().await.unwrap().event, SessionEventWire::SystemNotice(_)));
1296        t.detach().await;
1297        assert!(t.next_event().await.is_none());
1298        assert!(t.next_event().await.is_none());
1299        srv.await.unwrap();
1300    }
1301
1302    #[tokio::test]
1303    async fn conversation_digest_miss_requeries_messages() {
1304        let (_d, path) = sock();
1305        let l = Listener::bind(&path).unwrap();
1306        let srv = tokio::spawn(fake_daemon(l, PROTOCOL_VERSION));
1307        let conn = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.unwrap();
1308        let (mut t, _) = SocketTransport::attach(conn, Attach::Existing { session_id: "fake-1".into(), mode: AttachMode::Mirror })
1309            .await
1310            .unwrap();
1311        assert!(t.messages().is_empty());
1312        t.send(SessionCommand::Steer { text: "drifted".into() }).await.unwrap();
1313        // The digest misses the (empty) mirror → transport re-queries → one
1314        // Conversation with the fetched history and the digest's cost.
1315        let e = t.next_event().await.unwrap();
1316        match e.event {
1317            SessionEventWire::Conversation(c) => {
1318                assert_eq!(c.api_messages.len(), 1);
1319                assert_eq!(c.api_messages[0]["content"], "drifted");
1320                assert_eq!(c.cost, 1.5);
1321            }
1322            other => panic!("{other:?}"),
1323        }
1324        assert_eq!(t.messages().len(), 1);
1325        // Same digest again now matches the mirror: no re-query, served locally.
1326        t.send(SessionCommand::Steer { text: "drifted".into() }).await.unwrap();
1327        let e = t.next_event().await.unwrap();
1328        assert!(matches!(e.event, SessionEventWire::Conversation(ref c) if c.api_messages.len() == 1));
1329        t.detach().await;
1330        assert!(t.next_event().await.is_none());
1331        srv.await.unwrap();
1332    }
1333
1334    #[tokio::test]
1335    async fn reloading_then_bye_sets_last_error_and_reconnect_gives_up_on_a_dead_socket() {
1336        let (_d, path) = sock();
1337        let l = Listener::bind(&path).unwrap();
1338        let srv = tokio::spawn(fake_daemon(l, PROTOCOL_VERSION));
1339        let conn = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.unwrap();
1340        let (mut t, _) = SocketTransport::attach(conn, Attach::Existing { session_id: "fake-1".into(), mode: AttachMode::Observe })
1341            .await
1342            .unwrap();
1343        assert_eq!(t.mode(), AttachMode::Observe);
1344        assert!(t.last_error().is_none());
1345        t.send(SessionCommand::Checkpoint { reason: CheckpointReason::Reload }).await.unwrap();
1346        let e = t.next_event().await.unwrap();
1347        assert!(matches!(e.event, SessionEventWire::Reloading { generation: 2, .. }));
1348        assert!(t.next_event().await.is_none());
1349        assert!(matches!(t.last_error(), Some(TransportError::Reloading { generation: 2 })));
1350        srv.await.unwrap();
1351        // Nobody listens any more: the backoff loop gives up at the budget.
1352        std::env::set_var("SYNAPS_TUI_ATTACH_RECONNECT_SECS", "1");
1353        let r = t.reconnect(AttachMode::Observe).await;
1354        std::env::remove_var("SYNAPS_TUI_ATTACH_RECONNECT_SECS");
1355        assert!(r.is_err());
1356    }
1357
1358    #[tokio::test]
1359    async fn attach_error_frame_is_refused() {
1360        let (_d, path) = sock();
1361        let mut l = Listener::bind(&path).unwrap();
1362        // A daemon that welcomes, then answers the Attach with an Error frame.
1363        let srv = tokio::spawn(async move {
1364            let stream = l.accept().await.unwrap();
1365            let (r, mut w) = crate::ipc::split_server(stream);
1366            let mut reader = BufReader::new(r);
1367            let mut line = String::new();
1368            reader.read_line(&mut line).await.unwrap();
1369            let welcome = DaemonFrame::Welcome(Welcome {
1370                protocol_version: PROTOCOL_VERSION,
1371                daemon_version: "t".into(),
1372                pid: 1,
1373                profile: None,
1374                sessions: vec![],
1375                progressive_tool_disclosure: true,
1376                generation: 1,
1377                daemon_build: String::new(),
1378            });
1379            w.write_all(encode_line(&welcome).unwrap().as_bytes()).await.unwrap();
1380            line.clear();
1381            reader.read_line(&mut line).await.unwrap();
1382            let err = DaemonFrame::Error { session_id: None, message: "unknown session".into() };
1383            w.write_all(encode_line(&err).unwrap().as_bytes()).await.unwrap();
1384        });
1385        let conn = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.unwrap();
1386        let err = SocketTransport::attach(conn, Attach::Existing { session_id: "nope".into(), mode: AttachMode::Mirror })
1387            .await
1388            .err()
1389            .unwrap();
1390        assert!(matches!(err, TransportError::Refused(ref m) if m == "unknown session"), "{err}");
1391        srv.await.unwrap();
1392    }
1393
1394    #[tokio::test]
1395    async fn socket_transport_refuses_version_mismatch() {
1396        let (_d, path) = sock();
1397        let l = Listener::bind(&path).unwrap();
1398        let srv = tokio::spawn(fake_daemon(l, 99));
1399        let err = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.err().unwrap();
1400        assert!(matches!(err, TransportError::Version { client: PROTOCOL_VERSION, daemon: 99 }), "{err}");
1401        srv.await.unwrap();
1402    }
1403
1404    /// An older daemon answers `Close` with serde's unknown-variant error;
1405    /// the client reports "too old" (exit 2), not a generic failure.
1406    #[test]
1407    fn close_maps_each_daemon_answer_and_an_old_daemons_unknown_variant_error() {
1408        let old = DaemonFrame::Error {
1409            session_id: None,
1410            message: "malformed frame: unknown variant `close`, expected one of `hello`, `ping`".into(),
1411        };
1412        let err = close_reply(Some(old), "0.9.0").unwrap_err();
1413        assert!(matches!(err, TransportError::DaemonTooOld { ref daemon_version, .. } if daemon_version == "0.9.0"), "{err}");
1414        assert!(err.to_string().contains("synaps daemon reload"), "{err}");
1415
1416        let closed = DaemonFrame::Closed { session_id: "s".into(), text: "closing session s".into() };
1417        assert_eq!(close_reply(Some(closed), "1").unwrap(), "closing session s");
1418        let busy = DaemonFrame::Refused { reason: RefuseReason::Busy, message: "not closed: a turn is running".into() };
1419        assert!(matches!(close_reply(Some(busy), "1"), Err(TransportError::Refused(ref m)) if m.contains("turn")));
1420        let unknown = DaemonFrame::Refused { reason: RefuseReason::UnknownSession, message: "no live session s".into() };
1421        assert!(matches!(close_reply(Some(unknown), "1"), Err(TransportError::UnknownSession(_))));
1422        let other = DaemonFrame::Error { session_id: None, message: "close: no reply".into() };
1423        assert!(matches!(close_reply(Some(other), "1"), Err(TransportError::Protocol(_))));
1424        assert!(matches!(close_reply(None, "1"), Err(TransportError::Closed)));
1425    }
1426
1427    #[test]
1428    fn pin_maps_each_daemon_answer_and_an_old_daemons_unknown_variant_error() {
1429        let old = DaemonFrame::Error {
1430            session_id: None,
1431            message: "malformed frame: unknown variant `pin`, expected one of `hello`, `ping`".into(),
1432        };
1433        let err = pin_reply(Some(old), "0.9.0").unwrap_err();
1434        assert!(
1435            matches!(err, TransportError::DaemonTooOld { ref daemon_version, request: "daemon pin" } if daemon_version == "0.9.0"),
1436            "{err}"
1437        );
1438        assert!(err.to_string().contains("synaps daemon reload"), "{err}");
1439
1440        assert!(pin_reply(Some(DaemonFrame::Pinned { session_id: "s".into(), on: true }), "1").unwrap());
1441        assert!(!pin_reply(Some(DaemonFrame::Pinned { session_id: "s".into(), on: false }), "1").unwrap());
1442        let busy = DaemonFrame::Refused { reason: RefuseReason::Busy, message: "daemon reloading".into() };
1443        assert!(matches!(pin_reply(Some(busy), "1"), Err(TransportError::Refused(ref m)) if m.contains("reloading")));
1444        let unknown = DaemonFrame::Refused { reason: RefuseReason::UnknownSession, message: "no live session s".into() };
1445        assert!(matches!(pin_reply(Some(unknown), "1"), Err(TransportError::UnknownSession(_))));
1446        let other = DaemonFrame::Error { session_id: None, message: "pin: timed out".into() };
1447        assert!(matches!(pin_reply(Some(other), "1"), Err(TransportError::Protocol(_))));
1448        let closed = DaemonFrame::Closed { session_id: "s".into(), text: "x".into() };
1449        assert!(matches!(pin_reply(Some(closed), "1"), Err(TransportError::Protocol(_))));
1450        assert!(matches!(pin_reply(None, "1"), Err(TransportError::Closed)));
1451    }
1452
1453    #[test]
1454    fn rename_maps_each_daemon_answer_and_an_old_daemons_unknown_variant_error() {
1455        use crate::session::wire::RenameRefusal;
1456        let old = DaemonFrame::Error {
1457            session_id: None,
1458            message: "malformed frame: unknown variant `rename`, expected one of `hello`, `ping`".into(),
1459        };
1460        let err = rename_reply(Some(old), "0.9.0").unwrap_err();
1461        assert!(
1462            matches!(err, TransportError::DaemonTooOld { ref daemon_version, request: "daemon rename" } if daemon_version == "0.9.0"),
1463            "{err}"
1464        );
1465        assert!(err.to_string().contains("predates `daemon rename`"), "{err}");
1466
1467        let ok = DaemonFrame::Renamed { session_id: "s".into(), name: Some("axon".into()), in_memory_only: false };
1468        assert_eq!(rename_reply(Some(ok), "1").unwrap(), Ok(Renamed { name: Some("axon".into()), in_memory_only: false }));
1469        let cleared = DaemonFrame::Renamed { session_id: "s".into(), name: None, in_memory_only: true };
1470        assert_eq!(rename_reply(Some(cleared), "1").unwrap(), Ok(Renamed { name: None, in_memory_only: true }));
1471        let taken = DaemonFrame::RenameFailed {
1472            session_id: "s".into(),
1473            reason: RenameRefusal::Taken,
1474            message: "name 'axon' already used by session t".into(),
1475            taken_by: Some("t".into()),
1476            current_name: None,
1477        };
1478        let failure = rename_reply(Some(taken), "1").unwrap().unwrap_err();
1479        assert_eq!((failure.reason, failure.taken_by.as_deref()), (RenameRefusal::Taken, Some("t")));
1480        let busy = DaemonFrame::Refused { reason: RefuseReason::Busy, message: "daemon reloading".into() };
1481        assert!(matches!(rename_reply(Some(busy), "1"), Err(TransportError::Refused(_))));
1482        let unknown = DaemonFrame::Refused { reason: RefuseReason::UnknownSession, message: "no live session s".into() };
1483        assert!(matches!(rename_reply(Some(unknown), "1"), Err(TransportError::UnknownSession(_))));
1484        let other = DaemonFrame::Error { session_id: None, message: "rename: no reply".into() };
1485        assert!(matches!(rename_reply(Some(other), "1"), Err(TransportError::Protocol(_))));
1486        assert!(matches!(rename_reply(None, "1"), Err(TransportError::Closed)));
1487    }
1488
1489    #[test]
1490    fn reload_extension_maps_each_daemon_answer_and_an_old_daemons_unknown_variant_error() {
1491        let old = DaemonFrame::Error {
1492            session_id: None,
1493            message: "malformed frame: unknown variant `reload_extension`, expected one of `hello`, `ping`".into(),
1494        };
1495        let err = reload_extension_reply(Some(old), "0.9.0").unwrap_err();
1496        assert!(
1497            matches!(err, TransportError::DaemonTooOld { ref daemon_version, request: "daemon reload-extension" } if daemon_version == "0.9.0"),
1498            "{err}"
1499        );
1500
1501        let report = ExtensionReloadReport {
1502            extension: "axel".into(),
1503            was_loaded: true,
1504            deferred: false,
1505            tools: vec!["axel:axel_search".into()],
1506            removed_tools: vec![],
1507            plugin_dir: std::path::PathBuf::from("/p/axel"),
1508        };
1509        let ok = DaemonFrame::ExtensionReloaded { report: report.clone() };
1510        assert_eq!(reload_extension_reply(Some(ok), "1").unwrap(), Ok(report));
1511        let failed = DaemonFrame::ExtensionReloadFailed { extension: "axel".into(), message: "spawn failed".into(), unloaded: true };
1512        assert_eq!(
1513            reload_extension_reply(Some(failed), "1").unwrap(),
1514            Err(ExtensionReloadFailure { message: "spawn failed".into(), unloaded: true })
1515        );
1516        let busy = DaemonFrame::Refused { reason: RefuseReason::Busy, message: "daemon reloading".into() };
1517        assert!(matches!(reload_extension_reply(Some(busy), "1"), Err(TransportError::Refused(ref m)) if m.contains("reloading")));
1518        // A plain `Error` (not the unknown-variant one) stays a protocol error.
1519        let other = DaemonFrame::Error { session_id: None, message: "frame too large".into() };
1520        assert!(matches!(reload_extension_reply(Some(other), "1"), Err(TransportError::Protocol(_))));
1521        let pinned = DaemonFrame::Pinned { session_id: "s".into(), on: true };
1522        assert!(matches!(reload_extension_reply(Some(pinned), "1"), Err(TransportError::Protocol(_))));
1523        assert!(matches!(reload_extension_reply(None, "1"), Err(TransportError::Closed)));
1524    }
1525
1526    #[tokio::test]
1527    async fn socket_transport_connect_refused_when_nobody_listens() {
1528        let (_d, path) = sock();
1529        let err = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.err().unwrap();
1530        assert!(matches!(err, TransportError::Io(_)));
1531    }
1532}