1use serde::{Deserialize, Serialize};
4use serde_json::{json, Map, Value};
5use uuid::Uuid;
6
7use crate::VERSION;
8
9pub const PROTO_VERSION: &str = "1";
11
12pub const CLIENT_VERSION: &str = VERSION;
14
15pub const DEFAULT_CLIENT_CAPABILITIES: &[&str] = &["streaming", "batch", "heartbeat", "receipts"];
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum MessageType {
22 ConnectionInit,
24 ConnectionAck,
26 Request,
28 Response,
30 Notification,
32 Subscribe,
34 Next,
36 Error,
38 Complete,
40 Unsubscribe,
42 Ping,
44 Pong,
46 ReceiptResponse,
48 Disconnect,
50 Status,
52}
53
54impl MessageType {
55 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
79pub struct ErrorObject {
80 pub code: i64,
82 pub message: String,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub data: Option<Value>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
91pub struct Envelope {
92 #[serde(default)]
94 pub proto: String,
95 #[serde(rename = "type")]
97 pub msg_type: String,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub method: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub params: Option<Map<String, Value>>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub id: Option<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub result: Option<Map<String, Value>>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub error: Option<ErrorObject>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub payload: Option<Value>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub receipt: Option<String>,
119 #[serde(flatten)]
121 pub extra: Map<String, Value>,
122}
123
124impl Envelope {
125 pub fn to_wire_json(&self) -> Result<String, serde_json::Error> {
127 serde_json::to_string(self)
128 }
129
130 pub fn to_value(&self) -> Value {
132 serde_json::to_value(self).unwrap_or(Value::Null)
133 }
134}
135
136pub fn new_request_id() -> String {
138 Uuid::new_v4().simple().to_string()
139}
140
141pub 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
161pub 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
193pub 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
213pub 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
233pub 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
249pub 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
270pub 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
286pub 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
302pub fn new_disconnect() -> Envelope {
304 new_notification("disconnect", Map::new())
305}
306
307pub fn decode_message(text: &str) -> Result<Value, serde_json::Error> {
309 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
322pub 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
336pub 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
358pub fn as_str(v: &Value) -> &str {
360 v.as_str().unwrap_or("")
361}
362
363pub 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
372pub 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#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
395pub struct BaseMessage {
396 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub request_id: Option<String>,
399 #[serde(default, skip_serializing_if = "Option::is_none")]
401 #[allow(missing_docs)]
402 pub r#type: Option<String>,
403}
404
405#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
407pub struct EventMessage {
408 #[serde(flatten)]
410 pub base: BaseMessage,
411 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub loop_id: Option<String>,
414 #[serde(default, skip_serializing_if = "Option::is_none")]
416 pub namespace: Option<Value>,
417 #[serde(default, skip_serializing_if = "Option::is_none")]
419 pub mode: Option<String>,
420 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub data: Option<Value>,
423 #[serde(default, skip_serializing_if = "Option::is_none")]
425 pub timestamp: Option<String>,
426}
427
428impl EventMessage {
429 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 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 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#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
490pub struct LoopAIMessage {
491 #[serde(default, skip_serializing_if = "Option::is_none")]
493 #[allow(missing_docs)]
494 pub r#type: Option<String>,
495 #[serde(default, skip_serializing_if = "Option::is_none")]
497 pub content: Option<Value>,
498 #[serde(default, skip_serializing_if = "Option::is_none")]
500 pub phase: Option<String>,
501}
502
503impl LoopAIMessage {
504 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#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
536pub struct StatusResponse {
537 #[serde(flatten)]
539 pub base: BaseMessage,
540 #[serde(default, skip_serializing_if = "Option::is_none")]
542 pub state: Option<String>,
543 #[serde(default, skip_serializing_if = "Option::is_none")]
545 pub loop_id: Option<String>,
546 #[serde(default, skip_serializing_if = "Option::is_none", rename = "loopId")]
548 pub loop_id_camel: Option<String>,
549 #[serde(default, skip_serializing_if = "Option::is_none")]
551 pub workspace: Option<String>,
552}
553
554impl StatusResponse {
555 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
564pub 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
583pub 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#[derive(Debug, Clone, PartialEq)]
599pub enum TypedMessage {
600 Envelope(Envelope),
602 Event(EventMessage),
604 Status(StatusResponse),
606 Value(Value),
608}
609
610impl TypedMessage {
611 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
645pub fn next_to_event_message(env: &Envelope) -> Option<EventMessage> {
654 let payload = env.payload.as_ref()?.as_object()?;
655 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 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
714pub 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}