Skip to main content

soothe_client/
protocol.rs

1//! Protocol-1 wire envelope encode/decode.
2
3use serde::{Deserialize, Serialize};
4use serde_json::{json, Map, Value};
5use uuid::Uuid;
6
7use crate::VERSION;
8
9/// Protocol version string.
10pub const PROTO_VERSION: &str = "1";
11
12/// Client version reported in `connection_init` (crate version).
13pub const CLIENT_VERSION: &str = VERSION;
14
15/// Default capabilities declared in the handshake.
16pub const DEFAULT_CLIENT_CAPABILITIES: &[&str] = &["streaming", "batch", "heartbeat", "receipts"];
17
18/// Message class values for the envelope `type` field.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum MessageType {
22    /// Client→server handshake.
23    ConnectionInit,
24    /// Server→client handshake ack.
25    ConnectionAck,
26    /// RPC request.
27    Request,
28    /// RPC success response.
29    Response,
30    /// Fire-and-forget notification.
31    Notification,
32    /// Start a subscription.
33    Subscribe,
34    /// Stream event.
35    Next,
36    /// Structured error.
37    Error,
38    /// Stream completion.
39    Complete,
40    /// Cancel subscription.
41    Unsubscribe,
42    /// Heartbeat ping.
43    Ping,
44    /// Heartbeat pong.
45    Pong,
46    /// Receipt confirmation.
47    ReceiptResponse,
48    /// Graceful disconnect.
49    Disconnect,
50    /// Status frame (often top-level).
51    Status,
52}
53
54impl MessageType {
55    /// Wire string.
56    pub fn as_str(self) -> &'static str {
57        match self {
58            Self::ConnectionInit => "connection_init",
59            Self::ConnectionAck => "connection_ack",
60            Self::Request => "request",
61            Self::Response => "response",
62            Self::Notification => "notification",
63            Self::Subscribe => "subscribe",
64            Self::Next => "next",
65            Self::Error => "error",
66            Self::Complete => "complete",
67            Self::Unsubscribe => "unsubscribe",
68            Self::Ping => "ping",
69            Self::Pong => "pong",
70            Self::ReceiptResponse => "receipt_response",
71            Self::Disconnect => "disconnect",
72            Self::Status => "status",
73        }
74    }
75}
76
77/// Structured error nested under envelope.error.
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
79pub struct ErrorObject {
80    /// Numeric code.
81    pub code: i64,
82    /// Message.
83    pub message: String,
84    /// Optional data.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub data: Option<Value>,
87}
88
89/// Unified `{proto, type, method, params, id}` envelope.
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
91pub struct Envelope {
92    /// Protocol version (defaults to empty when omitted by legacy frames).
93    #[serde(default)]
94    pub proto: String,
95    /// Message class.
96    #[serde(rename = "type")]
97    pub msg_type: String,
98    /// RPC / subscription method.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub method: Option<String>,
101    /// Structured params.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub params: Option<Map<String, Value>>,
104    /// Correlation id.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub id: Option<String>,
107    /// Success result.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub result: Option<Map<String, Value>>,
110    /// Structured error.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub error: Option<ErrorObject>,
113    /// Stream payload (`next`).
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub payload: Option<Value>,
116    /// Optional receipt id.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub receipt: Option<String>,
119    /// Extra fields (status frames, etc.).
120    #[serde(flatten)]
121    pub extra: Map<String, Value>,
122}
123
124impl Envelope {
125    /// Compact JSON text (no spaces).
126    pub fn to_wire_json(&self) -> Result<String, serde_json::Error> {
127        serde_json::to_string(self)
128    }
129
130    /// Convert to a generic JSON object.
131    pub fn to_value(&self) -> Value {
132        serde_json::to_value(self).unwrap_or(Value::Null)
133    }
134}
135
136/// Generate a 32-hex request id (UUID without dashes).
137pub fn new_request_id() -> String {
138    Uuid::new_v4().simple().to_string()
139}
140
141/// Build a request envelope with a generated id.
142pub fn new_request(method: impl Into<String>, params: Map<String, Value>) -> Envelope {
143    Envelope {
144        proto: PROTO_VERSION.to_string(),
145        msg_type: "request".into(),
146        method: Some(method.into()),
147        params: if params.is_empty() {
148            None
149        } else {
150            Some(params)
151        },
152        id: Some(new_request_id()),
153        result: None,
154        error: None,
155        payload: None,
156        receipt: None,
157        extra: Map::new(),
158    }
159}
160
161/// Build a request envelope with an explicit id.
162///
163/// When `id` is empty, a new id is generated (Go `NewRequestEnvelopeWithID` parity).
164pub fn new_request_with_id(
165    method: impl Into<String>,
166    params: Map<String, Value>,
167    id: impl Into<String>,
168) -> Envelope {
169    let id = id.into();
170    let id = if id.trim().is_empty() {
171        new_request_id()
172    } else {
173        id
174    };
175    Envelope {
176        proto: PROTO_VERSION.to_string(),
177        msg_type: "request".into(),
178        method: Some(method.into()),
179        params: if params.is_empty() {
180            None
181        } else {
182            Some(params)
183        },
184        id: Some(id),
185        result: None,
186        error: None,
187        payload: None,
188        receipt: None,
189        extra: Map::new(),
190    }
191}
192
193/// Build a notification envelope (no id).
194pub fn new_notification(method: impl Into<String>, params: Map<String, Value>) -> Envelope {
195    Envelope {
196        proto: PROTO_VERSION.to_string(),
197        msg_type: "notification".into(),
198        method: Some(method.into()),
199        params: if params.is_empty() {
200            None
201        } else {
202            Some(params)
203        },
204        id: None,
205        result: None,
206        error: None,
207        payload: None,
208        receipt: None,
209        extra: Map::new(),
210    }
211}
212
213/// Build a subscribe envelope.
214pub fn new_subscribe(method: impl Into<String>, params: Map<String, Value>) -> Envelope {
215    Envelope {
216        proto: PROTO_VERSION.to_string(),
217        msg_type: "subscribe".into(),
218        method: Some(method.into()),
219        params: if params.is_empty() {
220            None
221        } else {
222            Some(params)
223        },
224        id: Some(new_request_id()),
225        result: None,
226        error: None,
227        payload: None,
228        receipt: None,
229        extra: Map::new(),
230    }
231}
232
233/// Build an unsubscribe envelope.
234pub fn new_unsubscribe(id: impl Into<String>) -> Envelope {
235    Envelope {
236        proto: PROTO_VERSION.to_string(),
237        msg_type: "unsubscribe".into(),
238        method: None,
239        params: None,
240        id: Some(id.into()),
241        result: None,
242        error: None,
243        payload: None,
244        receipt: None,
245        extra: Map::new(),
246    }
247}
248
249/// Build connection_init.
250pub fn new_connection_init() -> Envelope {
251    let mut params = Map::new();
252    params.insert("client_version".into(), json!(CLIENT_VERSION));
253    params.insert("client_name".into(), json!("soothe-client-rust"));
254    params.insert("accept_proto".into(), json!([PROTO_VERSION]));
255    params.insert("capabilities".into(), json!(DEFAULT_CLIENT_CAPABILITIES));
256    Envelope {
257        proto: PROTO_VERSION.to_string(),
258        msg_type: "connection_init".into(),
259        method: None,
260        params: Some(params),
261        id: None,
262        result: None,
263        error: None,
264        payload: None,
265        receipt: None,
266        extra: Map::new(),
267    }
268}
269
270/// Build ping.
271pub fn new_ping() -> Envelope {
272    Envelope {
273        proto: PROTO_VERSION.to_string(),
274        msg_type: "ping".into(),
275        method: None,
276        params: None,
277        id: None,
278        result: None,
279        error: None,
280        payload: None,
281        receipt: None,
282        extra: Map::new(),
283    }
284}
285
286/// Build pong.
287pub fn new_pong() -> Envelope {
288    Envelope {
289        proto: PROTO_VERSION.to_string(),
290        msg_type: "pong".into(),
291        method: None,
292        params: None,
293        id: None,
294        result: None,
295        error: None,
296        payload: None,
297        receipt: None,
298        extra: Map::new(),
299    }
300}
301
302/// Build disconnect notification.
303pub fn new_disconnect() -> Envelope {
304    new_notification("disconnect", Map::new())
305}
306
307/// Decode a WebSocket text frame into a JSON value.
308pub fn decode_message(text: &str) -> Result<Value, serde_json::Error> {
309    // Support rare NDJSON frames: take first non-empty line if multiple.
310    let trimmed = text.trim();
311    if trimmed.contains('\n') {
312        for line in trimmed.lines() {
313            let line = line.trim();
314            if !line.is_empty() {
315                return serde_json::from_str(line);
316            }
317        }
318    }
319    serde_json::from_str(trimmed)
320}
321
322/// Expand `event_batch` frames into individual events.
323pub fn expand_wire_messages(msg: Value) -> Vec<Value> {
324    let Some(obj) = msg.as_object() else {
325        return vec![msg];
326    };
327    if obj.get("type").and_then(|v| v.as_str()) != Some("event_batch") {
328        return vec![msg];
329    }
330    match obj.get("events").and_then(|v| v.as_array()) {
331        Some(events) if !events.is_empty() => events.clone(),
332        _ => vec![msg],
333    }
334}
335
336/// Unwrap a `next` envelope to its inner frame when possible.
337///
338/// Matches Go `appkit.UnwrapNext`: when `type==next` and `payload.data` is an
339/// object, return that object; otherwise return the original message.
340pub fn unwrap_next(msg: &Value) -> Value {
341    let Some(obj) = msg.as_object() else {
342        return msg.clone();
343    };
344    if obj.get("type").and_then(|v| v.as_str()) != Some("next") {
345        return msg.clone();
346    }
347    let Some(payload) = obj.get("payload").and_then(|p| p.as_object()) else {
348        return msg.clone();
349    };
350    if let Some(data) = payload.get("data") {
351        if data.is_object() {
352            return data.clone();
353        }
354    }
355    msg.clone()
356}
357
358/// Coerce JSON value to string.
359pub fn as_str(v: &Value) -> &str {
360    v.as_str().unwrap_or("")
361}
362
363/// Map helper: insert string.
364pub fn params_map(pairs: &[(&str, Value)]) -> Map<String, Value> {
365    let mut m = Map::new();
366    for (k, v) in pairs {
367        m.insert((*k).to_string(), v.clone());
368    }
369    m
370}
371
372/// Split a WebSocket text payload into one or more JSON objects.
373///
374/// The daemon may send newline-delimited JSON (NDJSON) in a single frame.
375/// Each non-empty trimmed line is returned as a separate byte slice (Go
376/// `SplitSootheWirePayload` parity).
377pub fn split_wire_payload(data: &str) -> Vec<&str> {
378    let trimmed = data.trim();
379    if trimmed.is_empty() {
380        return Vec::new();
381    }
382    let mut out: Vec<&str> = trimmed
383        .lines()
384        .map(|l| l.trim())
385        .filter(|l| !l.is_empty())
386        .collect();
387    if out.is_empty() {
388        out.push(trimmed);
389    }
390    out
391}
392
393/// Common message base with type and optional request_id.
394#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
395pub struct BaseMessage {
396    /// Optional request id.
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub request_id: Option<String>,
399    /// Frame type (`event`, `status`, etc.).
400    #[serde(default, skip_serializing_if = "Option::is_none")]
401    #[allow(missing_docs)]
402    pub r#type: Option<String>,
403}
404
405/// Streaming event frame from the daemon (Go `EventMessage` parity).
406#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
407pub struct EventMessage {
408    /// Base message fields.
409    #[serde(flatten)]
410    pub base: BaseMessage,
411    /// Loop id this event belongs to.
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    pub loop_id: Option<String>,
414    /// Event namespace (string or list form).
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub namespace: Option<Value>,
417    /// Stream mode (`messages`, `updates`, `custom`, `event`).
418    #[serde(default, skip_serializing_if = "Option::is_none")]
419    pub mode: Option<String>,
420    /// Event payload.
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub data: Option<Value>,
423    /// Optional timestamp.
424    #[serde(default, skip_serializing_if = "Option::is_none")]
425    pub timestamp: Option<String>,
426}
427
428impl EventMessage {
429    /// Normalized event type: prefer `data.type`, fall back to `namespace` as string.
430    pub fn event_type(&self) -> String {
431        if let Some(data) = self.data.as_ref().and_then(|d| d.as_object()) {
432            if let Some(t) = data.get("type").and_then(|v| v.as_str()) {
433                if !t.is_empty() {
434                    return t.to_string();
435                }
436            }
437        }
438        if let Some(s) = self.namespace.as_ref().and_then(|n| n.as_str()) {
439            if !s.is_empty() {
440                return s.to_string();
441            }
442        }
443        String::new()
444    }
445
446    /// Namespace path segments regardless of wire encoding (string or list).
447    pub fn namespace_parts(&self) -> Vec<String> {
448        match &self.namespace {
449            Some(Value::String(s)) if !s.is_empty() => {
450                s.split('.').map(|p| p.to_string()).collect()
451            }
452            Some(Value::Array(arr)) => arr
453                .iter()
454                .filter_map(|v| v.as_str().map(|s| s.to_string()))
455                .collect(),
456            _ => Vec::new(),
457        }
458    }
459
460    /// Extract a loop-tagged assistant message from `mode=messages` events.
461    ///
462    /// Returns `None` for non-message events or non-assistant payloads.
463    pub fn loop_ai_message(&self) -> Option<LoopAIMessage> {
464        if self.mode.as_deref() != Some("messages") {
465            return None;
466        }
467        let items = self.data.as_ref().and_then(|d| d.as_array())?;
468        let first = items.first()?;
469        let msg_map = first.as_object()?;
470        let phase_str = msg_map.get("phase").and_then(|v| v.as_str()).unwrap_or("");
471        if phase_str.is_empty() {
472            return None;
473        }
474        if !is_loop_assistant_phase(phase_str) {
475            return None;
476        }
477        let type_str = msg_map.get("type").and_then(|v| v.as_str()).unwrap_or("ai");
478        let msg = LoopAIMessage {
479            r#type: Some(type_str.to_string()),
480            content: msg_map.get("content").cloned(),
481            phase: Some(phase_str.to_string()),
482        };
483        Some(msg)
484    }
485}
486
487/// Loop-tagged assistant payload forwarded on `mode=messages` streams
488/// (Go `LoopAIMessage` parity).
489#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
490pub struct LoopAIMessage {
491    /// Message type (typically `ai`).
492    #[serde(default, skip_serializing_if = "Option::is_none")]
493    #[allow(missing_docs)]
494    pub r#type: Option<String>,
495    /// Message content (string, array of blocks, or object).
496    #[serde(default, skip_serializing_if = "Option::is_none")]
497    pub content: Option<Value>,
498    /// Stream phase (`text_completion`, `goal_completion`, etc.).
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    pub phase: Option<String>,
501}
502
503impl LoopAIMessage {
504    /// Extract plain text from the content payload.
505    pub fn loop_ai_text(&self) -> String {
506        match &self.content {
507            Some(Value::String(s)) => s.clone(),
508            Some(Value::Array(items)) => {
509                let mut buf = String::new();
510                for item in items {
511                    if let Some(s) = item.as_str() {
512                        buf.push_str(s);
513                        continue;
514                    }
515                    if let Some(blk) = item.as_object() {
516                        if let Some(t) = blk.get("text").and_then(|v| v.as_str()) {
517                            buf.push_str(t);
518                        }
519                    }
520                }
521                buf
522            }
523            Some(Value::Object(obj)) => obj
524                .get("text")
525                .and_then(|v| v.as_str())
526                .unwrap_or("")
527                .to_string(),
528            _ => String::new(),
529        }
530    }
531}
532
533/// Status acknowledgment (Go `StatusResponse` parity). Handles the camelCase
534/// `loopId` fallback emitted by some daemon builds.
535#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
536pub struct StatusResponse {
537    /// Base message fields.
538    #[serde(flatten)]
539    pub base: BaseMessage,
540    /// Daemon state (`running`, `idle`, `stopped`, `detached`).
541    #[serde(default, skip_serializing_if = "Option::is_none")]
542    pub state: Option<String>,
543    /// Loop id (snake_case form).
544    #[serde(default, skip_serializing_if = "Option::is_none")]
545    pub loop_id: Option<String>,
546    /// CamelCase fallback (`loopId`) emitted by some daemon builds.
547    #[serde(default, skip_serializing_if = "Option::is_none", rename = "loopId")]
548    pub loop_id_camel: Option<String>,
549    /// Workspace path.
550    #[serde(default, skip_serializing_if = "Option::is_none")]
551    pub workspace: Option<String>,
552}
553
554impl StatusResponse {
555    /// Resolved loop id (prefers snake_case, falls back to camelCase).
556    pub fn resolved_loop_id(&self) -> Option<&str> {
557        self.loop_id
558            .as_deref()
559            .filter(|s| !s.is_empty())
560            .or_else(|| self.loop_id_camel.as_deref().filter(|s| !s.is_empty()))
561    }
562}
563
564/// True when `phase` tags streamable loop assistant output.
565///
566/// Broader than `DEFAULT_DELIVERABLE_PHASES` (which only lists phases that may
567/// end a turn) — this includes narration phases like `plan_direct`.
568pub fn is_loop_assistant_phase(phase: &str) -> bool {
569    matches!(
570        phase,
571        "goal_completion"
572            | "quiz"
573            | "autonomous_goal"
574            | "text_completion"
575            | "image_to_text"
576            | "ocr"
577            | "embed"
578            | "plan_direct"
579            | "chitchat"
580    )
581}
582
583/// Decode a WebSocket text frame into a typed message (Go `DecodeMessage` parity).
584///
585/// Dispatches by the `type` field:
586/// - Protocol-1 envelope types → `Envelope`
587/// - `next` whose payload wraps an event-shaped frame → `EventMessage`
588/// - `event` → `EventMessage`
589/// - `status` → `StatusResponse` (with camelCase `loopId` fallback)
590/// - `event_batch` → raw `Value` (use [`expand_wire_messages`] to flatten)
591/// - Unknown → raw `Value`
592pub fn decode_message_typed(text: &str) -> Result<TypedMessage, serde_json::Error> {
593    let value = decode_message(text)?;
594    Ok(TypedMessage::from_value(value))
595}
596
597/// Typed decoded message (Go `interface{}` return parity).
598#[derive(Debug, Clone, PartialEq)]
599pub enum TypedMessage {
600    /// Protocol-1 envelope.
601    Envelope(Envelope),
602    /// Streaming event frame (from `event` or projected `next`).
603    Event(EventMessage),
604    /// Status frame.
605    Status(StatusResponse),
606    /// Raw value (unknown type or `event_batch`).
607    Value(Value),
608}
609
610impl TypedMessage {
611    /// Dispatch a decoded JSON value into the typed variant.
612    pub fn from_value(value: Value) -> Self {
613        let Some(t) = value.get("type").and_then(|v| v.as_str()) else {
614            return Self::Value(value);
615        };
616        match t {
617            "connection_init" | "connection_ack" | "request" | "response" | "notification"
618            | "subscribe" | "error" | "complete" | "unsubscribe" | "ping" | "pong"
619            | "receipt_response" | "disconnect" => {
620                match serde_json::from_value::<Envelope>(value.clone()) {
621                    Ok(env) => Self::Envelope(env),
622                    Err(_) => Self::Value(value),
623                }
624            }
625            "next" => match serde_json::from_value::<Envelope>(value.clone()) {
626                Ok(env) => match next_to_event_message(&env) {
627                    Some(em) => Self::Event(em),
628                    None => Self::Envelope(env),
629                },
630                Err(_) => Self::Value(value),
631            },
632            "event" => match serde_json::from_value::<EventMessage>(value.clone()) {
633                Ok(em) => Self::Event(em),
634                Err(_) => Self::Value(value),
635            },
636            "status" => match serde_json::from_value::<StatusResponse>(value.clone()) {
637                Ok(s) => Self::Status(s),
638                Err(_) => Self::Value(value),
639            },
640            _ => Self::Value(value),
641        }
642    }
643}
644
645/// Project a protocol-1 `next` envelope's payload into an [`EventMessage`] when
646/// the payload carries a wrapped legacy event frame (Go `nextToEventMessage` parity).
647///
648/// The daemon wraps legacy free-form frames as
649/// `{payload:{namespace, mode:<orig type>, data:<orig frame>}}`. The original
650/// event frame (with its own type/mode/data/loop_id) lives inside `payload.data`,
651/// so we project from the inner frame to preserve the legacy `EventMessage`
652/// shape (Mode, Data, LoopID, Namespace) that consumers expect.
653pub fn next_to_event_message(env: &Envelope) -> Option<EventMessage> {
654    let payload = env.payload.as_ref()?.as_object()?;
655    // Common case: payload.data is the original event frame.
656    if let Some(inner) = payload.get("data").and_then(|d| d.as_object()) {
657        let inner_type = inner.get("type").and_then(|v| v.as_str()).unwrap_or("");
658        if (inner_type == "event" || inner_type.is_empty()) && inner.contains_key("mode") {
659            let em = EventMessage {
660                base: BaseMessage {
661                    r#type: Some("event".to_string()),
662                    request_id: None,
663                },
664                loop_id: inner
665                    .get("loop_id")
666                    .and_then(|v| v.as_str())
667                    .filter(|s| !s.is_empty())
668                    .map(|s| s.to_string())
669                    .or_else(|| {
670                        inner
671                            .get("loopId")
672                            .and_then(|v| v.as_str())
673                            .filter(|s| !s.is_empty())
674                            .map(|s| s.to_string())
675                    }),
676                namespace: inner.get("namespace").cloned(),
677                mode: inner
678                    .get("mode")
679                    .and_then(|v| v.as_str())
680                    .map(|s| s.to_string()),
681                data: inner.get("data").cloned(),
682                timestamp: None,
683            };
684            return Some(em);
685        }
686    }
687    // Fallback: payload itself carries mode/data directly.
688    let has_mode = payload.contains_key("mode");
689    let has_data = payload.contains_key("data");
690    if has_mode || has_data {
691        let em = EventMessage {
692            base: BaseMessage {
693                r#type: Some("event".to_string()),
694                request_id: None,
695            },
696            loop_id: payload
697                .get("loop_id")
698                .and_then(|v| v.as_str())
699                .filter(|s| !s.is_empty())
700                .map(|s| s.to_string()),
701            namespace: payload.get("namespace").cloned(),
702            mode: payload
703                .get("mode")
704                .and_then(|v| v.as_str())
705                .map(|s| s.to_string()),
706            data: payload.get("data").cloned(),
707            timestamp: None,
708        };
709        return Some(em);
710    }
711    None
712}
713
714/// Extract a loop id from a decoded message (Go `ExtractSootheLoopID` parity).
715///
716/// Handles protocol-1 envelopes (next.payload.data.loop_id, status.params.loop_id),
717/// [`EventMessage`], [`StatusResponse`], and raw maps (including camelCase `loopId`).
718pub fn extract_soothe_loop_id(msg: &Value) -> Option<String> {
719    let obj = msg.as_object()?;
720    let t = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
721    match t {
722        "next" => {
723            let payload = obj.get("payload").and_then(|p| p.as_object())?;
724            if let Some(data) = payload.get("data").and_then(|d| d.as_object()) {
725                if let Some(s) = data
726                    .get("loop_id")
727                    .and_then(|v| v.as_str())
728                    .filter(|s| !s.is_empty())
729                {
730                    return Some(s.to_string());
731                }
732                if let Some(s) = data
733                    .get("loopId")
734                    .and_then(|v| v.as_str())
735                    .filter(|s| !s.is_empty())
736                {
737                    return Some(s.to_string());
738                }
739            }
740            payload
741                .get("loop_id")
742                .and_then(|v| v.as_str())
743                .filter(|s| !s.is_empty())
744                .map(|s| s.to_string())
745        }
746        "status" => obj
747            .get("params")
748            .and_then(|p| p.get("loop_id"))
749            .and_then(|v| v.as_str())
750            .filter(|s| !s.is_empty())
751            .map(|s| s.to_string())
752            .or_else(|| {
753                obj.get("loop_id")
754                    .and_then(|v| v.as_str())
755                    .filter(|s| !s.is_empty())
756                    .map(|s| s.to_string())
757            })
758            .or_else(|| {
759                obj.get("loopId")
760                    .and_then(|v| v.as_str())
761                    .filter(|s| !s.is_empty())
762                    .map(|s| s.to_string())
763            }),
764        _ => obj
765            .get("loop_id")
766            .and_then(|v| v.as_str())
767            .filter(|s| !s.is_empty())
768            .map(|s| s.to_string())
769            .or_else(|| {
770                obj.get("loopId")
771                    .and_then(|v| v.as_str())
772                    .filter(|s| !s.is_empty())
773                    .map(|s| s.to_string())
774            }),
775    }
776}
777
778#[cfg(test)]
779mod tests {
780    use super::*;
781
782    #[test]
783    fn request_id_is_32_hex() {
784        let id = new_request_id();
785        assert_eq!(id.len(), 32);
786        assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
787    }
788
789    #[test]
790    fn connection_init_shape() {
791        let env = new_connection_init();
792        assert_eq!(env.msg_type, "connection_init");
793        assert_eq!(env.proto, "1");
794        let params = env.params.unwrap();
795        assert_eq!(params["client_name"], json!("soothe-client-rust"));
796    }
797
798    #[test]
799    fn expand_event_batch() {
800        let batch = json!({
801            "type": "event_batch",
802            "events": [{"type":"event","mode":"messages"}, {"type":"status","state":"idle"}]
803        });
804        let expanded = expand_wire_messages(batch);
805        assert_eq!(expanded.len(), 2);
806    }
807
808    #[test]
809    fn unwrap_next_returns_payload_data() {
810        let frame = json!({
811            "type": "next",
812            "payload": {
813                "mode": "event",
814                "data": {"type": "event", "mode": "messages", "data": [{"content": "hi"}]}
815            }
816        });
817        let inner = unwrap_next(&frame);
818        assert_eq!(inner["mode"], json!("messages"));
819        assert_eq!(inner["type"], json!("event"));
820    }
821
822    #[test]
823    fn new_request_with_id_explicit() {
824        let env = new_request_with_id("loop_get", Map::new(), "abc123");
825        assert_eq!(env.id.as_deref(), Some("abc123"));
826        assert_eq!(env.msg_type, "request");
827    }
828
829    #[test]
830    fn new_request_with_id_empty_generates() {
831        let env = new_request_with_id("loop_get", Map::new(), "");
832        assert!(env.id.is_some());
833        assert!(!env.id.unwrap().is_empty());
834    }
835
836    #[test]
837    fn split_ndjson_payload() {
838        let line1 = r#"{"type":"status","state":"running"}"#;
839        let line2 = r#"{"type":"event","mode":"messages"}"#;
840        let data = format!("{line1}\n{line2}");
841        let frames = split_wire_payload(&data);
842        assert_eq!(frames.len(), 2);
843        assert_eq!(frames[0], line1);
844        assert_eq!(frames[1], line2);
845    }
846
847    #[test]
848    fn split_single_frame_payload() {
849        let data = r#"{"type":"status","state":"idle"}"#;
850        let frames = split_wire_payload(data);
851        assert_eq!(frames.len(), 1);
852    }
853
854    #[test]
855    fn split_empty_payload() {
856        assert!(split_wire_payload("").is_empty());
857        assert!(split_wire_payload("   \n  \n").is_empty());
858    }
859
860    #[test]
861    fn decode_message_typed_status() {
862        let text = r#"{"type":"status","state":"running","loop_id":"abc"}"#;
863        let typed = decode_message_typed(text).unwrap();
864        match typed {
865            TypedMessage::Status(s) => {
866                assert_eq!(s.state.as_deref(), Some("running"));
867                assert_eq!(s.resolved_loop_id(), Some("abc"));
868            }
869            other => panic!("expected Status, got {other:?}"),
870        }
871    }
872
873    #[test]
874    fn decode_message_typed_status_camel_loop_id() {
875        let text = r#"{"type":"status","state":"idle","loopId":"xyz"}"#;
876        let typed = decode_message_typed(text).unwrap();
877        match typed {
878            TypedMessage::Status(s) => {
879                assert_eq!(s.resolved_loop_id(), Some("xyz"));
880            }
881            other => panic!("expected Status, got {other:?}"),
882        }
883    }
884
885    #[test]
886    fn decode_message_typed_next_projects_event() {
887        let text = r#"{"type":"next","payload":{"mode":"event","data":{"type":"event","mode":"messages","data":[{"content":"hi"}],"loop_id":"L1"}}}"#;
888        let typed = decode_message_typed(text).unwrap();
889        match typed {
890            TypedMessage::Event(em) => {
891                assert_eq!(em.mode.as_deref(), Some("messages"));
892                assert_eq!(em.loop_id.as_deref(), Some("L1"));
893            }
894            other => panic!("expected Event, got {other:?}"),
895        }
896    }
897
898    #[test]
899    fn decode_message_typed_event() {
900        let text = r#"{"type":"event","mode":"custom","data":{"type":"foo"}}"#;
901        let typed = decode_message_typed(text).unwrap();
902        match typed {
903            TypedMessage::Event(em) => {
904                assert_eq!(em.mode.as_deref(), Some("custom"));
905                assert_eq!(em.event_type(), "foo");
906            }
907            other => panic!("expected Event, got {other:?}"),
908        }
909    }
910
911    #[test]
912    fn decode_message_typed_envelope() {
913        let text = r#"{"proto":"1","type":"response","id":"r1","result":{}}"#;
914        let typed = decode_message_typed(text).unwrap();
915        assert!(matches!(typed, TypedMessage::Envelope(_)));
916    }
917
918    #[test]
919    fn extract_loop_id_from_next() {
920        let v = json!({
921            "type": "next",
922            "payload": {"data": {"loop_id": "L99"}}
923        });
924        assert_eq!(extract_soothe_loop_id(&v).as_deref(), Some("L99"));
925    }
926
927    #[test]
928    fn extract_loop_id_camel_case() {
929        let v = json!({"type": "status", "loopId": "Camel"});
930        assert_eq!(extract_soothe_loop_id(&v).as_deref(), Some("Camel"));
931    }
932
933    #[test]
934    fn loop_ai_text_from_string() {
935        let m = LoopAIMessage {
936            r#type: Some("ai".into()),
937            content: Some(json!("hello")),
938            phase: Some("text_completion".into()),
939        };
940        assert_eq!(m.loop_ai_text(), "hello");
941    }
942
943    #[test]
944    fn loop_ai_text_from_block_array() {
945        let m = LoopAIMessage {
946            r#type: Some("ai".into()),
947            content: Some(json!([{"text": "block1"}, " ", {"text": "block2"}])),
948            phase: Some("text_completion".into()),
949        };
950        assert_eq!(m.loop_ai_text(), "block1 block2");
951    }
952
953    #[test]
954    fn loop_assistant_phase_broad() {
955        assert!(is_loop_assistant_phase("plan_direct"));
956        assert!(is_loop_assistant_phase("text_completion"));
957        assert!(!is_loop_assistant_phase("direct_llm"));
958    }
959}