Skip to main content

soothe_client/appkit/
daemon_session.rs

1//! Dual-socket DaemonSession for one conversation with turn streaming.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use serde_json::{json, Map, Value};
7use tokio::sync::Mutex;
8
9use crate::client::{unwrap_next_frame, Client, SendInputOptions};
10use crate::errors::{Error, Result};
11use crate::session::{bootstrap_loop_session, connect_with_retries, BootstrapOptions};
12use crate::stream_terminal::{is_turn_end_custom_data, is_turn_progress_chunk, STREAM_END};
13
14/// Options for constructing a DaemonSession.
15#[derive(Debug, Clone)]
16pub struct DaemonSessionOptions {
17    /// Workspace path.
18    pub workspace: Option<String>,
19    /// Stream delivery mode.
20    pub stream_delivery: String,
21    /// Post-idle drain window.
22    pub post_idle_drain: Duration,
23}
24
25impl Default for DaemonSessionOptions {
26    fn default() -> Self {
27        Self {
28            workspace: None,
29            stream_delivery: "adaptive".into(),
30            post_idle_drain: Duration::from_millis(500),
31        }
32    }
33}
34
35/// Options for `send_turn`.
36#[derive(Debug, Clone, Default)]
37pub struct SendTurnOptions {
38    /// Autonomous mode.
39    pub autonomous: bool,
40    /// Max iterations.
41    pub max_iterations: Option<u32>,
42    /// Preferred subagent.
43    pub preferred_subagent: Option<String>,
44    /// Model override.
45    pub model: Option<String>,
46    /// Model params.
47    pub model_params: Option<Value>,
48    /// Attachments.
49    pub attachments: Option<Value>,
50    /// Clarification mode.
51    pub clarification_mode: Option<String>,
52    /// Clarification answer.
53    pub clarification_answer: bool,
54    /// Intent hint.
55    pub intent_hint: Option<String>,
56}
57
58/// One streamed turn chunk.
59#[derive(Debug, Clone)]
60pub struct TurnChunk {
61    /// Namespace path.
62    pub namespace: Value,
63    /// Mode (`messages`, `custom`, …).
64    pub mode: String,
65    /// Payload data.
66    pub data: Value,
67}
68
69/// Dual-socket session: stream + lazy RPC sidecar.
70pub struct DaemonSession {
71    opts: DaemonSessionOptions,
72    client: Client,
73    rpc_client: Client,
74    rpc_connected: Mutex<bool>,
75    loop_id: Mutex<String>,
76    read_lock: Mutex<()>,
77    /// Last turn end state label.
78    pub last_turn_end_state: Mutex<String>,
79    /// Last turn error message.
80    pub last_turn_error_message: Mutex<String>,
81}
82
83impl DaemonSession {
84    /// Create a session for `ws_url`.
85    pub fn new(ws_url: impl Into<String>, opts: Option<DaemonSessionOptions>) -> Self {
86        let ws_url = ws_url.into();
87        Self {
88            client: Client::new(&ws_url),
89            rpc_client: Client::new(&ws_url),
90            rpc_connected: Mutex::new(false),
91            loop_id: Mutex::new(String::new()),
92            read_lock: Mutex::new(()),
93            last_turn_end_state: Mutex::new(String::new()),
94            last_turn_error_message: Mutex::new(String::new()),
95            opts: opts.unwrap_or_default(),
96        }
97    }
98
99    /// Stream socket.
100    pub fn stream_client(&self) -> &Client {
101        &self.client
102    }
103
104    /// Active loop id.
105    pub async fn loop_id(&self) -> String {
106        self.loop_id.lock().await.clone()
107    }
108
109    /// Connect and bootstrap a loop (or resume).
110    pub async fn connect(&self, resume_loop_id: Option<&str>) -> Result<Map<String, Value>> {
111        connect_with_retries(&self.client, 40, Duration::from_millis(250)).await?;
112        self.bootstrap_loop(resume_loop_id).await
113    }
114
115    async fn bootstrap_loop(&self, resume_loop_id: Option<&str>) -> Result<Map<String, Value>> {
116        let mut boot = BootstrapOptions::new();
117        boot.resume_loop_id = resume_loop_id.map(|s| s.to_string());
118        boot.workspace = self.opts.workspace.clone();
119        boot.stream_delivery = self.opts.stream_delivery.clone();
120        let ready = bootstrap_loop_session(&self.client, boot, None).await?;
121        if let Some(lid) = ready.get("loop_id").and_then(|v| v.as_str()) {
122            *self.loop_id.lock().await = lid.to_string();
123        }
124        Ok(ready)
125    }
126
127    /// Start a fresh loop on the stream socket.
128    pub async fn new_loop(&self) -> Result<Map<String, Value>> {
129        self.bootstrap_loop(None).await
130    }
131
132    /// Switch to an existing loop.
133    pub async fn switch_loop(&self, loop_id: &str) -> Result<Map<String, Value>> {
134        self.bootstrap_loop(Some(loop_id)).await
135    }
136
137    /// Reconnect + reattach, or fresh bootstrap if stale.
138    pub async fn ensure_connected(&self) -> Result<()> {
139        if self.client.is_connection_alive() {
140            return Ok(());
141        }
142        connect_with_retries(&self.client, 40, Duration::from_millis(250)).await?;
143        let lid = self.loop_id().await;
144        if lid.is_empty() {
145            self.bootstrap_loop(None).await?;
146            return Ok(());
147        }
148        match self.client.reattach_and_probe(&lid).await {
149            Ok(()) => Ok(()),
150            Err(Error::StaleLoop(_)) | Err(_) => {
151                // Close RPC sidecar; fresh bootstrap.
152                let _ = self.rpc_client.close().await;
153                *self.rpc_connected.lock().await = false;
154                self.bootstrap_loop(None).await?;
155                Ok(())
156            }
157        }
158    }
159
160    /// Close both sockets.
161    pub async fn close(&self) -> Result<()> {
162        let _ = self.client.close().await;
163        let _ = self.rpc_client.close().await;
164        *self.rpc_connected.lock().await = false;
165        Ok(())
166    }
167
168    /// Notify disconnect (loops keep running server-side).
169    pub async fn detach(&self) -> Result<()> {
170        self.client.notify("disconnect", Map::new()).await
171    }
172
173    /// Send a user turn on the stream socket.
174    pub async fn send_turn(&self, text: &str, opts: Option<SendTurnOptions>) -> Result<()> {
175        let loop_id = self.loop_id().await;
176        if loop_id.is_empty() {
177            return Err(Error::msg("no active loop session"));
178        }
179        let opts = opts.unwrap_or_default();
180        let input = SendInputOptions {
181            loop_id: Some(loop_id),
182            autonomous: opts.autonomous,
183            max_iterations: opts.max_iterations,
184            preferred_subagent: opts.preferred_subagent,
185            model: opts.model,
186            model_params: opts.model_params,
187            attachments: opts.attachments,
188            clarification_mode: opts.clarification_mode,
189            clarification_answer: opts.clarification_answer,
190            intent_hint: opts.intent_hint,
191            ..Default::default()
192        };
193        self.client.send_input(text, input).await
194    }
195
196    /// Cancel active turn via `/cancel`.
197    pub async fn cancel_active_turn(&self) -> Result<()> {
198        let mut params = Map::new();
199        params.insert("cmd".into(), json!("/cancel"));
200        self.client.notify("slash_command", params).await
201    }
202
203    async fn ensure_rpc_connected(&self) -> Result<()> {
204        let mut flag = self.rpc_connected.lock().await;
205        if *flag && self.rpc_client.is_connected() {
206            return Ok(());
207        }
208        connect_with_retries(&self.rpc_client, 5, Duration::from_millis(250)).await?;
209        *flag = true;
210        Ok(())
211    }
212
213    /// List loops via RPC sidecar.
214    pub async fn list_loops(&self, limit: u32) -> Result<Map<String, Value>> {
215        self.ensure_rpc_connected().await?;
216        let lim = if limit == 0 { 20 } else { limit };
217        self.rpc_client.loop_list(lim).await
218    }
219
220    /// Fetch cards via RPC sidecar.
221    pub async fn fetch_loop_cards(&self, loop_id: &str) -> Result<Map<String, Value>> {
222        self.ensure_rpc_connected().await?;
223        self.rpc_client.loop_cards_fetch(loop_id).await
224    }
225
226    /// Fetch history via RPC sidecar.
227    pub async fn fetch_loop_history(&self, loop_id: &str) -> Result<Map<String, Value>> {
228        self.ensure_rpc_connected().await?;
229        self.rpc_client.loop_history_fetch(loop_id).await
230    }
231
232    /// Stream turn chunks until idle / stream.end.
233    ///
234    /// `max_wait` of `None` means no absolute deadline.
235    pub async fn iter_turn_chunks(&self, max_wait: Option<Duration>) -> Result<Vec<TurnChunk>> {
236        let _guard = self.read_lock.lock().await;
237        self.iter_turn_chunks_locked(max_wait).await
238    }
239
240    async fn iter_turn_chunks_locked(&self, max_wait: Option<Duration>) -> Result<Vec<TurnChunk>> {
241        *self.last_turn_end_state.lock().await = String::new();
242        *self.last_turn_error_message.lock().await = String::new();
243
244        let mut out = Vec::new();
245        let mut query_started = false;
246        let mut expected_loop_id = self.loop_id().await;
247        let mut stream_payload_seen = false;
248        let mut turn_progress_seen = false;
249        let mut cancel_seen = false;
250        let absolute_deadline = max_wait.map(|d| tokio::time::Instant::now() + d);
251
252        let _ = self.client.peel_stale_pending_control_events().await;
253
254        loop {
255            if let Some(deadline) = absolute_deadline {
256                if tokio::time::Instant::now() > deadline {
257                    let err = format!(
258                        "turn timed out after {:?} (loop={})",
259                        max_wait.unwrap_or_default(),
260                        expected_loop_id
261                    );
262                    *self.last_turn_error_message.lock().await = err.clone();
263                    return Err(Error::msg(err));
264                }
265            }
266
267            let ev = self
268                .client
269                .read_event_with_timeout(Duration::from_millis(250))
270                .await?;
271            let Some(ev) = ev else {
272                if query_started && !self.client.is_connection_alive() {
273                    *self.last_turn_end_state.lock().await = "connection_lost".into();
274                    return Err(Error::msg("daemon connection lost"));
275                }
276                // Idle timeout on read — keep waiting unless absolute deadline.
277                continue;
278            };
279
280            let mut frame = ev;
281            let mut event_type = frame
282                .get("type")
283                .and_then(|v| v.as_str())
284                .unwrap_or("")
285                .to_string();
286            if event_type == "next" {
287                frame = unwrap_next_frame(&frame);
288                event_type = frame
289                    .get("type")
290                    .and_then(|v| v.as_str())
291                    .unwrap_or("")
292                    .to_string();
293            }
294
295            let event_loop_id = frame
296                .get("loop_id")
297                .and_then(|v| v.as_str())
298                .unwrap_or("")
299                .to_string();
300            if !expected_loop_id.is_empty()
301                && !event_loop_id.is_empty()
302                && event_loop_id != expected_loop_id
303            {
304                continue;
305            }
306
307            if event_type == "error" {
308                let msg = frame
309                    .get("error")
310                    .and_then(|e| e.get("message"))
311                    .and_then(|m| m.as_str())
312                    .or_else(|| frame.get("message").and_then(|m| m.as_str()))
313                    .unwrap_or("daemon error")
314                    .to_string();
315                *self.last_turn_error_message.lock().await = msg.clone();
316                return Err(Error::msg(msg));
317            }
318
319            if event_type == "status" {
320                if let Some(lid) = frame.get("loop_id").and_then(|v| v.as_str()) {
321                    if !lid.is_empty() {
322                        *self.loop_id.lock().await = lid.to_string();
323                        expected_loop_id = lid.to_string();
324                    }
325                }
326                let state = frame.get("state").and_then(|v| v.as_str()).unwrap_or("");
327                match state {
328                    "running" => query_started = true,
329                    "stopped" if query_started => {
330                        *self.last_turn_end_state.lock().await = state.into();
331                        self.drain_after_idle(&expected_loop_id, &mut out).await;
332                        return Ok(out);
333                    }
334                    "idle" if query_started => {
335                        if !stream_payload_seen && !cancel_seen {
336                            continue;
337                        }
338                        *self.last_turn_end_state.lock().await = state.into();
339                        self.drain_after_idle(&expected_loop_id, &mut out).await;
340                        return Ok(out);
341                    }
342                    _ => {}
343                }
344                continue;
345            }
346
347            if event_type == "command_response" {
348                let content = frame.get("content").and_then(|v| v.as_str()).unwrap_or("");
349                if content.contains("Cancellation requested") {
350                    cancel_seen = true;
351                }
352                continue;
353            }
354
355            if event_type != "event" {
356                continue;
357            }
358
359            let data = frame.get("data").cloned().unwrap_or(Value::Null);
360            let namespace = frame
361                .get("namespace")
362                .cloned()
363                .unwrap_or(Value::Array(vec![]));
364            let mode = frame
365                .get("mode")
366                .and_then(|v| v.as_str())
367                .unwrap_or("")
368                .to_string();
369
370            if mode == "custom"
371                && is_turn_end_custom_data(&data)
372                && (!query_started || !turn_progress_seen)
373            {
374                continue;
375            }
376
377            stream_payload_seen = true;
378            if is_turn_progress_chunk(&mode, &data) {
379                turn_progress_seen = true;
380            }
381
382            out.push(TurnChunk {
383                namespace,
384                mode: mode.clone(),
385                data: data.clone(),
386            });
387
388            if mode == "custom" && is_turn_end_custom_data(&data) {
389                let custom_type = data.get("type").and_then(|v| v.as_str()).unwrap_or("");
390                *self.last_turn_end_state.lock().await = if custom_type == STREAM_END {
391                    "stream_end".into()
392                } else {
393                    "completed".into()
394                };
395                self.drain_after_idle(&expected_loop_id, &mut out).await;
396                return Ok(out);
397            }
398        }
399    }
400
401    async fn drain_after_idle(&self, expected_loop_id: &str, out: &mut Vec<TurnChunk>) {
402        let deadline = tokio::time::Instant::now() + self.opts.post_idle_drain;
403        while tokio::time::Instant::now() < deadline {
404            let ev = match self
405                .client
406                .read_event_with_timeout(Duration::from_millis(250))
407                .await
408            {
409                Ok(Some(e)) => e,
410                _ => return,
411            };
412            let mut frame = ev;
413            let mut event_type = frame
414                .get("type")
415                .and_then(|v| v.as_str())
416                .unwrap_or("")
417                .to_string();
418            if event_type == "next" {
419                frame = unwrap_next_frame(&frame);
420                event_type = frame
421                    .get("type")
422                    .and_then(|v| v.as_str())
423                    .unwrap_or("")
424                    .to_string();
425            }
426            let event_loop_id = frame.get("loop_id").and_then(|v| v.as_str()).unwrap_or("");
427            if !expected_loop_id.is_empty()
428                && !event_loop_id.is_empty()
429                && event_loop_id != expected_loop_id
430            {
431                continue;
432            }
433            if event_type == "error" {
434                return;
435            }
436            if event_type == "status" {
437                if let Some(lid) = frame.get("loop_id").and_then(|v| v.as_str()) {
438                    if !lid.is_empty() {
439                        *self.loop_id.lock().await = lid.to_string();
440                    }
441                }
442                continue;
443            }
444            if event_type != "event" {
445                continue;
446            }
447            let data = frame.get("data").cloned().unwrap_or(Value::Null);
448            let namespace = frame
449                .get("namespace")
450                .cloned()
451                .unwrap_or(Value::Array(vec![]));
452            let mode = frame
453                .get("mode")
454                .and_then(|v| v.as_str())
455                .unwrap_or("")
456                .to_string();
457            out.push(TurnChunk {
458                namespace,
459                mode,
460                data,
461            });
462        }
463    }
464}
465
466impl DaemonSession {
467    /// Shared Arc constructor helper.
468    pub fn shared(ws_url: impl Into<String>, opts: Option<DaemonSessionOptions>) -> Arc<Self> {
469        Arc::new(Self::new(ws_url, opts))
470    }
471}