1use std::future::Future;
5use std::pin::Pin;
6use std::{
7 any::TypeId,
8 collections::HashMap,
9 sync::{LazyLock, Mutex},
10};
11
12use futures_core::Stream;
13use serde::{Deserialize, Serialize};
14
15use zeph_common::ToolName;
16
17pub use zeph_common::ToolDefinition;
18
19use crate::embed::owned_strs;
20use crate::error::LlmError;
21
22static SCHEMA_CACHE: LazyLock<Mutex<HashMap<TypeId, (serde_json::Value, String)>>> =
23 LazyLock::new(|| Mutex::new(HashMap::new()));
24
25pub(crate) fn cached_schema<T: schemars::JsonSchema + 'static>()
31-> Result<(serde_json::Value, String), crate::LlmError> {
32 let type_id = TypeId::of::<T>();
33 if let Ok(cache) = SCHEMA_CACHE.lock()
34 && let Some(entry) = cache.get(&type_id)
35 {
36 return Ok(entry.clone());
37 }
38 let schema = schemars::schema_for!(T);
39 let value = serde_json::to_value(&schema)
40 .map_err(|e| crate::LlmError::StructuredParse(e.to_string()))?;
41 let pretty = serde_json::to_string_pretty(&schema)
42 .map_err(|e| crate::LlmError::StructuredParse(e.to_string()))?;
43 if let Ok(mut cache) = SCHEMA_CACHE.lock() {
44 cache.insert(type_id, (value.clone(), pretty.clone()));
45 }
46 Ok((value, pretty))
47}
48
49pub(crate) fn short_type_name<T: ?Sized>() -> &'static str {
63 std::any::type_name::<T>()
64 .rsplit("::")
65 .next()
66 .unwrap_or("Output")
67}
68
69#[non_exhaustive]
78#[derive(Debug, Clone, Default)]
79pub struct ChatExtras {
80 pub entropy: Option<f64>,
85}
86
87impl ChatExtras {
88 #[must_use]
101 pub fn with_entropy(entropy: f64) -> Self {
102 Self {
103 entropy: Some(entropy),
104 }
105 }
106}
107
108#[non_exhaustive]
113#[derive(Debug, Clone)]
114pub enum StreamChunk {
115 Content(String),
117 Thinking(String),
119 Compaction(String),
122 ToolUse(Vec<ToolUseRequest>),
124}
125
126pub type ChatStream = Pin<Box<dyn Stream<Item = Result<StreamChunk, LlmError>> + Send>>;
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct ToolUseRequest {
139 pub id: String,
141 pub name: ToolName,
143 pub input: serde_json::Value,
145}
146
147#[non_exhaustive]
153#[derive(Debug, Clone)]
154pub enum ThinkingBlock {
155 Thinking { thinking: String, signature: String },
157 Redacted { data: String },
159}
160
161pub const MAX_TOKENS_TRUNCATION_MARKER: &str = "max_tokens limit reached";
164
165#[non_exhaustive]
173#[derive(Debug, Clone)]
174pub enum ChatResponse {
175 Text(String),
177 ToolUse {
179 text: Option<String>,
181 tool_calls: Vec<ToolUseRequest>,
182 thinking_blocks: Vec<ThinkingBlock>,
185 },
186}
187
188pub type EmbedFuture = Pin<Box<dyn Future<Output = Result<Vec<f32>, LlmError>> + Send>>;
190
191pub type EmbedFn = Box<dyn Fn(&str) -> EmbedFuture + Send + Sync>;
196
197pub type StatusTx = tokio::sync::mpsc::UnboundedSender<String>;
203
204#[must_use]
207pub fn default_debug_request_json(
208 messages: &[Message],
209 tools: &[ToolDefinition],
210) -> serde_json::Value {
211 serde_json::json!({
212 "model": serde_json::Value::Null,
213 "max_tokens": serde_json::Value::Null,
214 "messages": serde_json::to_value(messages).unwrap_or(serde_json::Value::Array(vec![])),
215 "tools": serde_json::to_value(tools).unwrap_or(serde_json::Value::Array(vec![])),
216 "temperature": serde_json::Value::Null,
217 "cache_control": serde_json::Value::Null,
218 })
219}
220
221#[derive(Debug, Clone, Default)]
230pub struct GenerationOverrides {
231 pub temperature: Option<f64>,
233 pub top_p: Option<f64>,
235 pub top_k: Option<usize>,
237 pub frequency_penalty: Option<f64>,
239 pub presence_penalty: Option<f64>,
241}
242
243#[non_exhaustive]
250#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(rename_all = "lowercase")]
252pub enum Role {
253 System,
254 User,
255 Assistant,
256}
257
258#[non_exhaustive]
273#[derive(Clone, Debug, Serialize, Deserialize)]
274#[serde(tag = "kind", rename_all = "snake_case")]
275pub enum MessagePart {
276 Text { text: String },
278 ToolOutput {
280 tool_name: zeph_common::ToolName,
281 body: String,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
283 compacted_at: Option<i64>,
284 },
285 Recall { text: String },
287 CodeContext { text: String },
289 Summary { text: String },
291 CrossSession { text: String },
293 ToolUse {
295 id: String,
296 name: String,
297 input: serde_json::Value,
298 },
299 ToolResult {
301 tool_use_id: String,
302 content: String,
303 #[serde(default)]
304 is_error: bool,
305 },
306 Image(Box<ImageData>),
308 ThinkingBlock { thinking: String, signature: String },
310 RedactedThinkingBlock { data: String },
312 Compaction { summary: String },
315}
316
317impl MessagePart {
318 #[must_use]
321 pub fn as_plain_text(&self) -> Option<&str> {
322 match self {
323 Self::Text { text }
324 | Self::Recall { text }
325 | Self::CodeContext { text }
326 | Self::Summary { text }
327 | Self::CrossSession { text } => Some(text.as_str()),
328 _ => None,
329 }
330 }
331
332 #[must_use]
334 pub fn as_image(&self) -> Option<&ImageData> {
335 if let Self::Image(img) = self {
336 Some(img)
337 } else {
338 None
339 }
340 }
341
342 #[must_use]
369 pub fn strip_images(parts: &[MessagePart]) -> Vec<MessagePart> {
370 parts
371 .iter()
372 .filter(|p| !matches!(p, MessagePart::Image(_)))
373 .cloned()
374 .collect()
375 }
376}
377
378#[derive(Clone, Serialize, Deserialize)]
379pub struct ImageData {
384 #[serde(with = "serde_bytes_base64")]
385 pub data: Vec<u8>,
386 pub mime_type: String,
387}
388
389impl std::fmt::Debug for ImageData {
390 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395 write!(f, "[image: {}, {} bytes]", self.mime_type, self.data.len())
396 }
397}
398
399mod serde_bytes_base64 {
400 use base64::{Engine, engine::general_purpose::STANDARD};
401 use serde::{Deserialize, Deserializer, Serializer};
402
403 pub fn serialize<S>(bytes: &[u8], s: S) -> Result<S::Ok, S::Error>
404 where
405 S: Serializer,
406 {
407 s.serialize_str(&STANDARD.encode(bytes))
408 }
409
410 pub fn deserialize<'de, D>(d: D) -> Result<Vec<u8>, D::Error>
411 where
412 D: Deserializer<'de>,
413 {
414 let s = String::deserialize(d)?;
415 STANDARD.decode(&s).map_err(serde::de::Error::custom)
416 }
417}
418
419#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
435#[serde(rename_all = "snake_case")]
436#[non_exhaustive]
437pub enum MessageVisibility {
438 Both,
440 AgentOnly,
442 UserOnly,
444}
445
446impl MessageVisibility {
447 #[must_use]
449 pub fn is_agent_visible(self) -> bool {
450 matches!(self, MessageVisibility::Both | MessageVisibility::AgentOnly)
451 }
452
453 #[must_use]
455 pub fn is_user_visible(self) -> bool {
456 matches!(self, MessageVisibility::Both | MessageVisibility::UserOnly)
457 }
458}
459
460impl Default for MessageVisibility {
461 fn default() -> Self {
463 MessageVisibility::Both
464 }
465}
466
467impl MessageVisibility {
468 #[must_use]
470 pub fn as_db_str(self) -> &'static str {
471 match self {
472 MessageVisibility::Both => "both",
473 MessageVisibility::AgentOnly => "agent_only",
474 MessageVisibility::UserOnly => "user_only",
475 }
476 }
477
478 #[must_use]
482 pub fn from_db_str(s: &str) -> Self {
483 match s {
484 "agent_only" => MessageVisibility::AgentOnly,
485 "user_only" => MessageVisibility::UserOnly,
486 _ => MessageVisibility::Both,
487 }
488 }
489}
490
491#[derive(Clone, Debug, Serialize, Deserialize)]
496pub struct MessageMetadata {
497 pub visibility: MessageVisibility,
499 #[serde(default, skip_serializing_if = "Option::is_none")]
501 pub compacted_at: Option<i64>,
502 #[serde(default, skip_serializing_if = "Option::is_none")]
505 pub deferred_summary: Option<String>,
506 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
509 pub focus_pinned: bool,
510 #[serde(default, skip_serializing_if = "Option::is_none")]
513 pub focus_marker_id: Option<uuid::Uuid>,
514 #[serde(skip)]
517 pub db_id: Option<i64>,
518 #[serde(default, skip_serializing_if = "Option::is_none")]
523 pub fidelity_tag: Option<zeph_common::ContextFidelity>,
524 #[serde(skip)]
528 pub embedding: Option<Vec<f32>>,
529 #[serde(default, skip_serializing_if = "Option::is_none")]
540 pub trust_level: Option<u8>,
541}
542
543impl Default for MessageMetadata {
544 fn default() -> Self {
545 Self {
546 visibility: MessageVisibility::Both,
547 compacted_at: None,
548 deferred_summary: None,
549 focus_pinned: false,
550 focus_marker_id: None,
551 db_id: None,
552 fidelity_tag: None,
553 embedding: None,
554 trust_level: None,
555 }
556 }
557}
558
559impl MessageMetadata {
560 #[must_use]
562 pub fn agent_only() -> Self {
563 Self {
564 visibility: MessageVisibility::AgentOnly,
565 compacted_at: None,
566 deferred_summary: None,
567 focus_pinned: false,
568 focus_marker_id: None,
569 db_id: None,
570 fidelity_tag: None,
571 embedding: None,
572 trust_level: None,
573 }
574 }
575
576 #[must_use]
578 pub fn user_only() -> Self {
579 Self {
580 visibility: MessageVisibility::UserOnly,
581 compacted_at: None,
582 deferred_summary: None,
583 focus_pinned: false,
584 focus_marker_id: None,
585 db_id: None,
586 fidelity_tag: None,
587 embedding: None,
588 trust_level: None,
589 }
590 }
591
592 #[must_use]
594 pub fn focus_pinned() -> Self {
595 Self {
596 visibility: MessageVisibility::AgentOnly,
597 compacted_at: None,
598 deferred_summary: None,
599 focus_pinned: true,
600 focus_marker_id: None,
601 db_id: None,
602 fidelity_tag: None,
603 embedding: None,
604 trust_level: None,
605 }
606 }
607}
608
609#[derive(Clone, Debug, Serialize, Deserialize)]
636pub struct Message {
637 pub role: Role,
638 pub content: String,
640 #[serde(default)]
641 pub parts: Vec<MessagePart>,
642 #[serde(default)]
643 pub metadata: MessageMetadata,
644}
645
646impl Default for Message {
647 fn default() -> Self {
648 Self {
649 role: Role::User,
650 content: String::new(),
651 parts: vec![],
652 metadata: MessageMetadata::default(),
653 }
654 }
655}
656
657impl Message {
658 #[must_use]
663 pub fn from_legacy(role: Role, content: impl Into<String>) -> Self {
664 Self {
665 role,
666 content: content.into(),
667 parts: vec![],
668 metadata: MessageMetadata::default(),
669 }
670 }
671
672 #[must_use]
677 pub fn from_parts(role: Role, parts: Vec<MessagePart>) -> Self {
678 let content = Self::flatten_parts(&parts);
679 Self {
680 role,
681 content,
682 parts,
683 metadata: MessageMetadata::default(),
684 }
685 }
686
687 #[must_use]
690 pub fn to_llm_content(&self) -> &str {
691 &self.content
692 }
693
694 pub fn rebuild_content(&mut self) {
696 if !self.parts.is_empty() {
697 self.content = Self::flatten_parts(&self.parts);
698 }
699 }
700
701 fn flatten_parts(parts: &[MessagePart]) -> String {
702 use std::fmt::Write;
703 let mut out = String::new();
704 for part in parts {
705 match part {
706 MessagePart::Text { text }
707 | MessagePart::Recall { text }
708 | MessagePart::CodeContext { text }
709 | MessagePart::Summary { text }
710 | MessagePart::CrossSession { text } => out.push_str(text),
711 MessagePart::ToolOutput {
712 tool_name,
713 body,
714 compacted_at,
715 } => {
716 if compacted_at.is_some() {
717 if body.is_empty() {
718 let _ = write!(out, "[tool output: {tool_name}] (pruned)");
719 } else {
720 let _ = write!(out, "[tool output: {tool_name}] {body}");
721 }
722 } else {
723 let _ = write!(out, "[tool output: {tool_name}]\n```\n{body}\n```");
724 }
725 }
726 MessagePart::ToolUse { id, name, .. } => {
727 let _ = write!(out, "[tool_use: {name}({id})]");
728 }
729 MessagePart::ToolResult {
730 tool_use_id,
731 content,
732 ..
733 } => {
734 let _ = write!(out, "[tool_result: {tool_use_id}]\n{content}");
735 }
736 MessagePart::Image(img) => {
737 let _ = write!(out, "[image: {}, {} bytes]", img.mime_type, img.data.len());
738 }
739 MessagePart::ThinkingBlock { .. }
741 | MessagePart::RedactedThinkingBlock { .. }
742 | MessagePart::Compaction { .. } => {}
743 }
744 }
745 out
746 }
747}
748
749pub trait LlmProvider: Send + Sync {
819 fn context_window(&self) -> Option<usize> {
823 None
824 }
825
826 fn chat(&self, messages: &[Message]) -> impl Future<Output = Result<String, LlmError>> + Send;
832
833 fn chat_stream(
839 &self,
840 messages: &[Message],
841 ) -> impl Future<Output = Result<ChatStream, LlmError>> + Send;
842
843 fn supports_streaming(&self) -> bool;
845
846 fn embed(&self, text: &str) -> impl Future<Output = Result<Vec<f32>, LlmError>> + Send;
852
853 fn embed_batch(
863 &self,
864 texts: &[&str],
865 ) -> impl Future<Output = Result<Vec<Vec<f32>>, LlmError>> + Send {
866 let owned = owned_strs(texts);
867 async move {
868 let mut results = Vec::with_capacity(owned.len());
869 for text in &owned {
870 results.push(self.embed(text).await?);
871 }
872 Ok(results)
873 }
874 }
875
876 fn supports_embeddings(&self) -> bool;
878
879 fn name(&self) -> &str;
881
882 #[allow(clippy::unnecessary_literal_bound)]
885 fn model_identifier(&self) -> &str {
886 ""
887 }
888
889 fn effective_model_identifier(&self) -> &str {
901 self.model_identifier()
902 }
903
904 fn supports_vision(&self) -> bool {
906 false
907 }
908
909 fn supports_tool_use(&self) -> bool {
916 false
917 }
918
919 fn chat_with_tools(
927 &self,
928 messages: &[Message],
929 _tools: &[ToolDefinition],
930 ) -> impl std::future::Future<Output = Result<ChatResponse, LlmError>> + Send {
931 let msgs = messages.to_vec();
932 async move { Ok(ChatResponse::Text(self.chat(&msgs).await?)) }
933 }
934
935 fn last_cache_usage(&self) -> Option<(u64, u64)> {
938 None
939 }
940
941 fn last_usage(&self) -> Option<(u64, u64)> {
944 None
945 }
946
947 fn last_reasoning_tokens(&self) -> Option<u64> {
952 None
953 }
954
955 fn last_ttft_ms(&self) -> Option<u64> {
970 None
971 }
972
973 fn take_compaction_summary(&self) -> Option<String> {
976 None
977 }
978
979 fn chat_with_extras(
994 &self,
995 messages: &[Message],
996 ) -> impl Future<Output = Result<(String, ChatExtras), LlmError>> + Send {
997 let msgs = messages.to_vec();
998 async move { Ok((self.chat(&msgs).await?, ChatExtras::default())) }
999 }
1000
1001 #[must_use]
1005 fn debug_request_json(
1006 &self,
1007 messages: &[Message],
1008 tools: &[ToolDefinition],
1009 _stream: bool,
1010 ) -> serde_json::Value {
1011 default_debug_request_json(messages, tools)
1012 }
1013
1014 fn list_models(&self) -> Vec<String> {
1017 vec![]
1018 }
1019
1020 fn supports_structured_output(&self) -> bool {
1022 false
1023 }
1024
1025 #[allow(async_fn_in_trait)]
1036 async fn chat_typed<T>(&self, messages: &[Message]) -> Result<T, LlmError>
1037 where
1038 T: serde::de::DeserializeOwned + schemars::JsonSchema + 'static,
1039 Self: Sized,
1040 {
1041 let (_, schema_json) = cached_schema::<T>()?;
1042 let type_name = short_type_name::<T>();
1043
1044 let mut augmented = messages.to_vec();
1045 let instruction = format!(
1046 "Respond with a valid JSON object matching this schema. \
1047 Output ONLY the JSON, no markdown fences or extra text.\n\n\
1048 Type: {type_name}\nSchema:\n```json\n{schema_json}\n```"
1049 );
1050 augmented.insert(0, Message::from_legacy(Role::System, instruction));
1051
1052 let raw = self.chat(&augmented).await?;
1053 let cleaned = strip_json_fences(&raw);
1054 match serde_json::from_str::<T>(cleaned) {
1055 Ok(val) => Ok(val),
1056 Err(first_err) => {
1057 augmented.push(Message::from_legacy(Role::Assistant, &raw));
1058 augmented.push(Message::from_legacy(
1059 Role::User,
1060 format!(
1061 "Your response was not valid JSON. Error: {first_err}. \
1062 Please output ONLY valid JSON matching the schema."
1063 ),
1064 ));
1065 let retry_raw = self.chat(&augmented).await?;
1066 let retry_cleaned = strip_json_fences(&retry_raw);
1067 serde_json::from_str::<T>(retry_cleaned).map_err(|e| {
1068 LlmError::StructuredParse(format!("parse failed after retry: {e}"))
1069 })
1070 }
1071 }
1072 }
1073}
1074
1075fn strip_json_fences(s: &str) -> &str {
1079 s.trim()
1080 .trim_start_matches("```json")
1081 .trim_start_matches("```")
1082 .trim_end_matches("```")
1083 .trim()
1084}
1085
1086#[cfg(test)]
1087mod tests {
1088 use std::assert_matches;
1089 use tokio_stream::StreamExt;
1090
1091 use super::*;
1092
1093 struct StubProvider {
1094 response: String,
1095 }
1096
1097 impl LlmProvider for StubProvider {
1098 async fn chat(&self, _messages: &[Message]) -> Result<String, LlmError> {
1099 Ok(self.response.clone())
1100 }
1101
1102 async fn chat_stream(&self, messages: &[Message]) -> Result<ChatStream, LlmError> {
1103 let response = self.chat(messages).await?;
1104 Ok(Box::pin(tokio_stream::once(Ok(StreamChunk::Content(
1105 response,
1106 )))))
1107 }
1108
1109 fn supports_streaming(&self) -> bool {
1110 false
1111 }
1112
1113 async fn embed(&self, _text: &str) -> Result<Vec<f32>, LlmError> {
1114 Ok(vec![0.1, 0.2, 0.3])
1115 }
1116
1117 fn supports_embeddings(&self) -> bool {
1118 false
1119 }
1120
1121 fn name(&self) -> &'static str {
1122 "stub"
1123 }
1124 }
1125
1126 #[test]
1127 fn test_image_data_debug_redacts_bytes() {
1128 let img = ImageData {
1129 data: vec![0xAB, 0xCD, 0xEF],
1130 mime_type: "image/png".to_owned(),
1131 };
1132 let debug = format!("{img:?}");
1133 assert_eq!(debug, "[image: image/png, 3 bytes]");
1134 assert!(!debug.contains("171") && !debug.contains("205") && !debug.contains("239"));
1135 }
1136
1137 #[test]
1138 fn context_window_default_returns_none() {
1139 let provider = StubProvider {
1140 response: String::new(),
1141 };
1142 assert!(provider.context_window().is_none());
1143 }
1144
1145 #[test]
1146 fn supports_streaming_default_returns_false() {
1147 let provider = StubProvider {
1148 response: String::new(),
1149 };
1150 assert!(!provider.supports_streaming());
1151 }
1152
1153 #[test]
1154 fn supports_tool_use_default_returns_false() {
1155 let provider = StubProvider {
1161 response: String::new(),
1162 };
1163 assert!(!provider.supports_tool_use());
1164 }
1165
1166 #[tokio::test]
1167 async fn chat_stream_default_yields_single_chunk() {
1168 let provider = StubProvider {
1169 response: "hello world".into(),
1170 };
1171 let messages = vec![Message {
1172 role: Role::User,
1173 content: "test".into(),
1174 parts: vec![],
1175 metadata: MessageMetadata::default(),
1176 }];
1177
1178 let mut stream = provider.chat_stream(&messages).await.unwrap();
1179 let chunk = stream.next().await.unwrap().unwrap();
1180 assert_matches!(chunk, StreamChunk::Content(s) if s == "hello world");
1181 assert!(stream.next().await.is_none());
1182 }
1183
1184 #[tokio::test]
1185 async fn chat_stream_default_propagates_chat_error() {
1186 struct FailProvider;
1187
1188 impl LlmProvider for FailProvider {
1189 async fn chat(&self, _messages: &[Message]) -> Result<String, LlmError> {
1190 Err(LlmError::Unavailable)
1191 }
1192
1193 async fn chat_stream(&self, messages: &[Message]) -> Result<ChatStream, LlmError> {
1194 let response = self.chat(messages).await?;
1195 Ok(Box::pin(tokio_stream::once(Ok(StreamChunk::Content(
1196 response,
1197 )))))
1198 }
1199
1200 fn supports_streaming(&self) -> bool {
1201 false
1202 }
1203
1204 async fn embed(&self, _text: &str) -> Result<Vec<f32>, LlmError> {
1205 Err(LlmError::Unavailable)
1206 }
1207
1208 fn supports_embeddings(&self) -> bool {
1209 false
1210 }
1211
1212 fn name(&self) -> &'static str {
1213 "fail"
1214 }
1215 }
1216
1217 let provider = FailProvider;
1218 let messages = vec![Message {
1219 role: Role::User,
1220 content: "test".into(),
1221 parts: vec![],
1222 metadata: MessageMetadata::default(),
1223 }];
1224
1225 let result = provider.chat_stream(&messages).await;
1226 assert!(result.is_err());
1227 if let Err(e) = result {
1228 assert!(e.to_string().contains("provider unavailable"));
1229 }
1230 }
1231
1232 #[tokio::test]
1233 async fn stub_provider_embed_returns_vector() {
1234 let provider = StubProvider {
1235 response: String::new(),
1236 };
1237 let embedding = provider.embed("test").await.unwrap();
1238 assert_eq!(embedding, vec![0.1, 0.2, 0.3]);
1239 }
1240
1241 #[tokio::test]
1242 async fn fail_provider_embed_propagates_error() {
1243 struct FailProvider;
1244
1245 impl LlmProvider for FailProvider {
1246 async fn chat(&self, _messages: &[Message]) -> Result<String, LlmError> {
1247 Err(LlmError::Unavailable)
1248 }
1249
1250 async fn chat_stream(&self, messages: &[Message]) -> Result<ChatStream, LlmError> {
1251 let response = self.chat(messages).await?;
1252 Ok(Box::pin(tokio_stream::once(Ok(StreamChunk::Content(
1253 response,
1254 )))))
1255 }
1256
1257 fn supports_streaming(&self) -> bool {
1258 false
1259 }
1260
1261 async fn embed(&self, _text: &str) -> Result<Vec<f32>, LlmError> {
1262 Err(LlmError::EmbedUnsupported {
1263 provider: "fail".into(),
1264 })
1265 }
1266
1267 fn supports_embeddings(&self) -> bool {
1268 false
1269 }
1270
1271 fn name(&self) -> &'static str {
1272 "fail"
1273 }
1274 }
1275
1276 let provider = FailProvider;
1277 let result = provider.embed("test").await;
1278 assert!(result.is_err());
1279 assert!(
1280 result
1281 .unwrap_err()
1282 .to_string()
1283 .contains("embedding not supported")
1284 );
1285 }
1286
1287 #[test]
1288 fn role_serialization() {
1289 let system = Role::System;
1290 let user = Role::User;
1291 let assistant = Role::Assistant;
1292
1293 assert_eq!(serde_json::to_string(&system).unwrap(), "\"system\"");
1294 assert_eq!(serde_json::to_string(&user).unwrap(), "\"user\"");
1295 assert_eq!(serde_json::to_string(&assistant).unwrap(), "\"assistant\"");
1296 }
1297
1298 #[test]
1299 fn role_deserialization() {
1300 let system: Role = serde_json::from_str("\"system\"").unwrap();
1301 let user: Role = serde_json::from_str("\"user\"").unwrap();
1302 let assistant: Role = serde_json::from_str("\"assistant\"").unwrap();
1303
1304 assert_eq!(system, Role::System);
1305 assert_eq!(user, Role::User);
1306 assert_eq!(assistant, Role::Assistant);
1307 }
1308
1309 #[test]
1310 fn message_clone() {
1311 let msg = Message {
1312 role: Role::User,
1313 content: "test".into(),
1314 parts: vec![],
1315 metadata: MessageMetadata::default(),
1316 };
1317 let cloned = msg.clone();
1318 assert_eq!(cloned.role, msg.role);
1319 assert_eq!(cloned.content, msg.content);
1320 }
1321
1322 #[test]
1323 fn message_debug() {
1324 let msg = Message {
1325 role: Role::Assistant,
1326 content: "response".into(),
1327 parts: vec![],
1328 metadata: MessageMetadata::default(),
1329 };
1330 let debug = format!("{msg:?}");
1331 assert!(debug.contains("Assistant"));
1332 assert!(debug.contains("response"));
1333 }
1334
1335 #[test]
1336 fn message_serialization() {
1337 let msg = Message {
1338 role: Role::User,
1339 content: "hello".into(),
1340 parts: vec![],
1341 metadata: MessageMetadata::default(),
1342 };
1343 let json = serde_json::to_string(&msg).unwrap();
1344 assert!(json.contains("\"role\":\"user\""));
1345 assert!(json.contains("\"content\":\"hello\""));
1346 }
1347
1348 #[test]
1349 fn message_part_serde_round_trip() {
1350 let parts = vec![
1351 MessagePart::Text {
1352 text: "hello".into(),
1353 },
1354 MessagePart::ToolOutput {
1355 tool_name: "bash".into(),
1356 body: "output".into(),
1357 compacted_at: None,
1358 },
1359 MessagePart::Recall {
1360 text: "recall".into(),
1361 },
1362 MessagePart::CodeContext {
1363 text: "code".into(),
1364 },
1365 MessagePart::Summary {
1366 text: "summary".into(),
1367 },
1368 ];
1369 let json = serde_json::to_string(&parts).unwrap();
1370 let deserialized: Vec<MessagePart> = serde_json::from_str(&json).unwrap();
1371 assert_eq!(deserialized.len(), 5);
1372 }
1373
1374 #[test]
1375 fn from_legacy_creates_empty_parts() {
1376 let msg = Message::from_legacy(Role::User, "hello");
1377 assert_eq!(msg.role, Role::User);
1378 assert_eq!(msg.content, "hello");
1379 assert!(msg.parts.is_empty());
1380 assert_eq!(msg.to_llm_content(), "hello");
1381 }
1382
1383 #[test]
1384 fn from_parts_flattens_content() {
1385 let msg = Message::from_parts(
1386 Role::System,
1387 vec![MessagePart::Recall {
1388 text: "recalled data".into(),
1389 }],
1390 );
1391 assert_eq!(msg.content, "recalled data");
1392 assert_eq!(msg.to_llm_content(), "recalled data");
1393 assert_eq!(msg.parts.len(), 1);
1394 }
1395
1396 #[test]
1397 fn from_parts_tool_output_format() {
1398 let msg = Message::from_parts(
1399 Role::User,
1400 vec![MessagePart::ToolOutput {
1401 tool_name: "bash".into(),
1402 body: "hello world".into(),
1403 compacted_at: None,
1404 }],
1405 );
1406 assert!(msg.content.contains("[tool output: bash]"));
1407 assert!(msg.content.contains("hello world"));
1408 }
1409
1410 #[test]
1411 fn message_deserializes_without_parts() {
1412 let json = r#"{"role":"user","content":"hello"}"#;
1413 let msg: Message = serde_json::from_str(json).unwrap();
1414 assert_eq!(msg.content, "hello");
1415 assert!(msg.parts.is_empty());
1416 }
1417
1418 #[test]
1419 fn flatten_skips_compacted_tool_output_empty_body() {
1420 let msg = Message::from_parts(
1422 Role::User,
1423 vec![
1424 MessagePart::Text {
1425 text: "prefix ".into(),
1426 },
1427 MessagePart::ToolOutput {
1428 tool_name: "bash".into(),
1429 body: String::new(),
1430 compacted_at: Some(1234),
1431 },
1432 MessagePart::Text {
1433 text: " suffix".into(),
1434 },
1435 ],
1436 );
1437 assert!(msg.content.contains("(pruned)"));
1438 assert!(msg.content.contains("prefix "));
1439 assert!(msg.content.contains(" suffix"));
1440 }
1441
1442 #[test]
1443 fn flatten_compacted_tool_output_with_reference_renders_body() {
1444 let ref_notice = "[tool output pruned; full content at /tmp/overflow/big.txt]";
1446 let msg = Message::from_parts(
1447 Role::User,
1448 vec![MessagePart::ToolOutput {
1449 tool_name: "bash".into(),
1450 body: ref_notice.into(),
1451 compacted_at: Some(1234),
1452 }],
1453 );
1454 assert!(msg.content.contains(ref_notice));
1455 assert!(!msg.content.contains("(pruned)"));
1456 }
1457
1458 #[test]
1459 fn rebuild_content_syncs_after_mutation() {
1460 let mut msg = Message::from_parts(
1461 Role::User,
1462 vec![MessagePart::ToolOutput {
1463 tool_name: "bash".into(),
1464 body: "original".into(),
1465 compacted_at: None,
1466 }],
1467 );
1468 assert!(msg.content.contains("original"));
1469
1470 if let MessagePart::ToolOutput {
1471 ref mut compacted_at,
1472 ref mut body,
1473 ..
1474 } = msg.parts[0]
1475 {
1476 *compacted_at = Some(999);
1477 body.clear(); }
1479 msg.rebuild_content();
1480
1481 assert!(msg.content.contains("(pruned)"));
1482 assert!(!msg.content.contains("original"));
1483 }
1484
1485 #[test]
1486 fn message_part_tool_use_serde_round_trip() {
1487 let part = MessagePart::ToolUse {
1488 id: "toolu_123".into(),
1489 name: "bash".into(),
1490 input: serde_json::json!({"command": "ls"}),
1491 };
1492 let json = serde_json::to_string(&part).unwrap();
1493 let deserialized: MessagePart = serde_json::from_str(&json).unwrap();
1494 if let MessagePart::ToolUse { id, name, input } = deserialized {
1495 assert_eq!(id, "toolu_123");
1496 assert_eq!(name, "bash");
1497 assert_eq!(input["command"], "ls");
1498 } else {
1499 panic!("expected ToolUse");
1500 }
1501 }
1502
1503 #[test]
1504 fn message_part_tool_result_serde_round_trip() {
1505 let part = MessagePart::ToolResult {
1506 tool_use_id: "toolu_123".into(),
1507 content: "file1.rs\nfile2.rs".into(),
1508 is_error: false,
1509 };
1510 let json = serde_json::to_string(&part).unwrap();
1511 let deserialized: MessagePart = serde_json::from_str(&json).unwrap();
1512 if let MessagePart::ToolResult {
1513 tool_use_id,
1514 content,
1515 is_error,
1516 } = deserialized
1517 {
1518 assert_eq!(tool_use_id, "toolu_123");
1519 assert_eq!(content, "file1.rs\nfile2.rs");
1520 assert!(!is_error);
1521 } else {
1522 panic!("expected ToolResult");
1523 }
1524 }
1525
1526 #[test]
1527 fn message_part_tool_result_is_error_default() {
1528 let json = r#"{"kind":"tool_result","tool_use_id":"id","content":"err"}"#;
1529 let part: MessagePart = serde_json::from_str(json).unwrap();
1530 if let MessagePart::ToolResult { is_error, .. } = part {
1531 assert!(!is_error);
1532 } else {
1533 panic!("expected ToolResult");
1534 }
1535 }
1536
1537 #[test]
1538 fn chat_response_construction() {
1539 let text = ChatResponse::Text("hello".into());
1540 assert_matches!(text, ChatResponse::Text(s) if s == "hello");
1541
1542 let tool_use = ChatResponse::ToolUse {
1543 text: Some("I'll run that".into()),
1544 tool_calls: vec![ToolUseRequest {
1545 id: "1".into(),
1546 name: "bash".into(),
1547 input: serde_json::json!({}),
1548 }],
1549 thinking_blocks: vec![],
1550 };
1551 assert_matches!(tool_use, ChatResponse::ToolUse { .. });
1552 }
1553
1554 #[test]
1555 fn flatten_parts_tool_use() {
1556 let msg = Message::from_parts(
1557 Role::Assistant,
1558 vec![MessagePart::ToolUse {
1559 id: "t1".into(),
1560 name: "bash".into(),
1561 input: serde_json::json!({"command": "ls"}),
1562 }],
1563 );
1564 assert!(msg.content.contains("[tool_use: bash(t1)]"));
1565 }
1566
1567 #[test]
1568 fn flatten_parts_tool_result() {
1569 let msg = Message::from_parts(
1570 Role::User,
1571 vec![MessagePart::ToolResult {
1572 tool_use_id: "t1".into(),
1573 content: "output here".into(),
1574 is_error: false,
1575 }],
1576 );
1577 assert!(msg.content.contains("[tool_result: t1]"));
1578 assert!(msg.content.contains("output here"));
1579 }
1580
1581 #[test]
1582 fn tool_definition_serde_round_trip() {
1583 let def = ToolDefinition {
1584 name: "bash".into(),
1585 description: "Execute a shell command".into(),
1586 parameters: serde_json::json!({"type": "object"}),
1587 output_schema: None,
1588 };
1589 let json = serde_json::to_string(&def).unwrap();
1590 let deserialized: ToolDefinition = serde_json::from_str(&json).unwrap();
1591 assert_eq!(deserialized.name, "bash");
1592 assert_eq!(deserialized.description, "Execute a shell command");
1593 }
1594
1595 #[tokio::test]
1596 async fn chat_with_tools_default_delegates_to_chat() {
1597 let provider = StubProvider {
1598 response: "hello".into(),
1599 };
1600 let messages = vec![Message::from_legacy(Role::User, "test")];
1601 let result = provider.chat_with_tools(&messages, &[]).await.unwrap();
1602 assert_matches!(result, ChatResponse::Text(s) if s == "hello");
1603 }
1604
1605 #[test]
1606 fn tool_output_compacted_at_serde_default() {
1607 let json = r#"{"kind":"tool_output","tool_name":"bash","body":"out"}"#;
1608 let part: MessagePart = serde_json::from_str(json).unwrap();
1609 if let MessagePart::ToolOutput { compacted_at, .. } = part {
1610 assert!(compacted_at.is_none());
1611 } else {
1612 panic!("expected ToolOutput");
1613 }
1614 }
1615
1616 #[test]
1619 fn strip_json_fences_plain_json() {
1620 assert_eq!(strip_json_fences(r#"{"a": 1}"#), r#"{"a": 1}"#);
1621 }
1622
1623 #[test]
1624 fn strip_json_fences_with_json_fence() {
1625 assert_eq!(strip_json_fences("```json\n{\"a\": 1}\n```"), r#"{"a": 1}"#);
1626 }
1627
1628 #[test]
1629 fn strip_json_fences_with_plain_fence() {
1630 assert_eq!(strip_json_fences("```\n{\"a\": 1}\n```"), r#"{"a": 1}"#);
1631 }
1632
1633 #[test]
1634 fn strip_json_fences_whitespace() {
1635 assert_eq!(strip_json_fences(" \n "), "");
1636 }
1637
1638 #[test]
1639 fn strip_json_fences_empty() {
1640 assert_eq!(strip_json_fences(""), "");
1641 }
1642
1643 #[test]
1644 fn strip_json_fences_outer_whitespace() {
1645 assert_eq!(
1646 strip_json_fences(" ```json\n{\"a\": 1}\n``` "),
1647 r#"{"a": 1}"#
1648 );
1649 }
1650
1651 #[test]
1652 fn strip_json_fences_only_opening_fence() {
1653 assert_eq!(strip_json_fences("```json\n{\"a\": 1}"), r#"{"a": 1}"#);
1654 }
1655
1656 #[derive(Debug, serde::Deserialize, schemars::JsonSchema, PartialEq)]
1659 struct TestOutput {
1660 value: String,
1661 }
1662
1663 struct SequentialStub {
1664 responses: std::sync::Mutex<Vec<Result<String, LlmError>>>,
1665 }
1666
1667 impl SequentialStub {
1668 fn new(responses: Vec<Result<String, LlmError>>) -> Self {
1669 Self {
1670 responses: std::sync::Mutex::new(responses),
1671 }
1672 }
1673 }
1674
1675 impl LlmProvider for SequentialStub {
1676 async fn chat(&self, _messages: &[Message]) -> Result<String, LlmError> {
1677 let mut responses = self.responses.lock().unwrap();
1678 if responses.is_empty() {
1679 return Err(LlmError::Other("no more responses".into()));
1680 }
1681 responses.remove(0)
1682 }
1683
1684 async fn chat_stream(&self, messages: &[Message]) -> Result<ChatStream, LlmError> {
1685 let response = self.chat(messages).await?;
1686 Ok(Box::pin(tokio_stream::once(Ok(StreamChunk::Content(
1687 response,
1688 )))))
1689 }
1690
1691 fn supports_streaming(&self) -> bool {
1692 false
1693 }
1694
1695 async fn embed(&self, _text: &str) -> Result<Vec<f32>, LlmError> {
1696 Err(LlmError::EmbedUnsupported {
1697 provider: "sequential-stub".into(),
1698 })
1699 }
1700
1701 fn supports_embeddings(&self) -> bool {
1702 false
1703 }
1704
1705 fn name(&self) -> &'static str {
1706 "sequential-stub"
1707 }
1708 }
1709
1710 #[tokio::test]
1711 async fn chat_typed_happy_path() {
1712 let provider = StubProvider {
1713 response: r#"{"value": "hello"}"#.into(),
1714 };
1715 let messages = vec![Message::from_legacy(Role::User, "test")];
1716 let result: TestOutput = provider.chat_typed(&messages).await.unwrap();
1717 assert_eq!(
1718 result,
1719 TestOutput {
1720 value: "hello".into()
1721 }
1722 );
1723 }
1724
1725 #[tokio::test]
1726 async fn chat_typed_retry_succeeds() {
1727 let provider = SequentialStub::new(vec![
1728 Ok("not valid json".into()),
1729 Ok(r#"{"value": "ok"}"#.into()),
1730 ]);
1731 let messages = vec![Message::from_legacy(Role::User, "test")];
1732 let result: TestOutput = provider.chat_typed(&messages).await.unwrap();
1733 assert_eq!(result, TestOutput { value: "ok".into() });
1734 }
1735
1736 #[tokio::test]
1737 async fn chat_typed_both_fail() {
1738 let provider = SequentialStub::new(vec![Ok("bad json".into()), Ok("still bad".into())]);
1739 let messages = vec![Message::from_legacy(Role::User, "test")];
1740 let result = provider.chat_typed::<TestOutput>(&messages).await;
1741 let err = result.unwrap_err();
1742 assert!(err.to_string().contains("parse failed after retry"));
1743 }
1744
1745 #[tokio::test]
1746 async fn chat_typed_chat_error_propagates() {
1747 let provider = SequentialStub::new(vec![Err(LlmError::Unavailable)]);
1748 let messages = vec![Message::from_legacy(Role::User, "test")];
1749 let result = provider.chat_typed::<TestOutput>(&messages).await;
1750 assert_matches!(result, Err(LlmError::Unavailable));
1751 }
1752
1753 #[tokio::test]
1754 async fn chat_typed_strips_fences() {
1755 let provider = StubProvider {
1756 response: "```json\n{\"value\": \"fenced\"}\n```".into(),
1757 };
1758 let messages = vec![Message::from_legacy(Role::User, "test")];
1759 let result: TestOutput = provider.chat_typed(&messages).await.unwrap();
1760 assert_eq!(
1761 result,
1762 TestOutput {
1763 value: "fenced".into()
1764 }
1765 );
1766 }
1767
1768 #[test]
1769 fn supports_structured_output_default_false() {
1770 let provider = StubProvider {
1771 response: String::new(),
1772 };
1773 assert!(!provider.supports_structured_output());
1774 }
1775
1776 #[test]
1777 fn structured_parse_error_display() {
1778 let err = LlmError::StructuredParse("test error".into());
1779 assert_eq!(
1780 err.to_string(),
1781 "structured output parse failed: test error"
1782 );
1783 }
1784
1785 #[test]
1786 fn message_part_image_roundtrip_json() {
1787 let part = MessagePart::Image(Box::new(ImageData {
1788 data: vec![1, 2, 3, 4],
1789 mime_type: "image/jpeg".into(),
1790 }));
1791 let json = serde_json::to_string(&part).unwrap();
1792 let decoded: MessagePart = serde_json::from_str(&json).unwrap();
1793 match decoded {
1794 MessagePart::Image(img) => {
1795 assert_eq!(img.data, vec![1, 2, 3, 4]);
1796 assert_eq!(img.mime_type, "image/jpeg");
1797 }
1798 _ => panic!("expected Image variant"),
1799 }
1800 }
1801
1802 #[test]
1803 fn flatten_parts_includes_image_placeholder() {
1804 let msg = Message::from_parts(
1805 Role::User,
1806 vec![
1807 MessagePart::Text {
1808 text: "see this".into(),
1809 },
1810 MessagePart::Image(Box::new(ImageData {
1811 data: vec![0u8; 100],
1812 mime_type: "image/png".into(),
1813 })),
1814 ],
1815 );
1816 let content = msg.to_llm_content();
1817 assert!(content.contains("see this"));
1818 assert!(content.contains("[image: image/png"));
1819 }
1820
1821 #[test]
1822 fn supports_vision_default_false() {
1823 let provider = StubProvider {
1824 response: String::new(),
1825 };
1826 assert!(!provider.supports_vision());
1827 }
1828
1829 #[test]
1830 fn message_metadata_default_both_visible() {
1831 let m = MessageMetadata::default();
1832 assert!(m.visibility.is_agent_visible());
1833 assert!(m.visibility.is_user_visible());
1834 assert_eq!(m.visibility, MessageVisibility::Both);
1835 assert!(m.compacted_at.is_none());
1836 }
1837
1838 #[test]
1839 fn message_metadata_agent_only() {
1840 let m = MessageMetadata::agent_only();
1841 assert!(m.visibility.is_agent_visible());
1842 assert!(!m.visibility.is_user_visible());
1843 assert_eq!(m.visibility, MessageVisibility::AgentOnly);
1844 }
1845
1846 #[test]
1847 fn message_metadata_user_only() {
1848 let m = MessageMetadata::user_only();
1849 assert!(!m.visibility.is_agent_visible());
1850 assert!(m.visibility.is_user_visible());
1851 assert_eq!(m.visibility, MessageVisibility::UserOnly);
1852 }
1853
1854 #[test]
1855 fn message_metadata_serde_default() {
1856 let json = r#"{"role":"user","content":"hello"}"#;
1857 let msg: Message = serde_json::from_str(json).unwrap();
1858 assert!(msg.metadata.visibility.is_agent_visible());
1859 assert!(msg.metadata.visibility.is_user_visible());
1860 }
1861
1862 #[test]
1863 fn message_metadata_round_trip() {
1864 let msg = Message {
1865 role: Role::User,
1866 content: "test".into(),
1867 parts: vec![],
1868 metadata: MessageMetadata::agent_only(),
1869 };
1870 let json = serde_json::to_string(&msg).unwrap();
1871 let decoded: Message = serde_json::from_str(&json).unwrap();
1872 assert!(decoded.metadata.visibility.is_agent_visible());
1873 assert!(!decoded.metadata.visibility.is_user_visible());
1874 assert_eq!(decoded.metadata.visibility, MessageVisibility::AgentOnly);
1875 }
1876
1877 #[test]
1878 fn message_part_compaction_round_trip() {
1879 let part = MessagePart::Compaction {
1880 summary: "Context was summarized.".to_owned(),
1881 };
1882 let json = serde_json::to_string(&part).unwrap();
1883 let decoded: MessagePart = serde_json::from_str(&json).unwrap();
1884 assert!(
1885 matches!(decoded, MessagePart::Compaction { summary } if summary == "Context was summarized.")
1886 );
1887 }
1888
1889 #[test]
1890 fn flatten_parts_compaction_contributes_no_text() {
1891 let parts = vec![
1894 MessagePart::Text {
1895 text: "Hello".to_owned(),
1896 },
1897 MessagePart::Compaction {
1898 summary: "Summary".to_owned(),
1899 },
1900 ];
1901 let msg = Message::from_parts(Role::Assistant, parts);
1902 assert_eq!(msg.content.trim(), "Hello");
1904 }
1905
1906 #[test]
1907 fn stream_chunk_compaction_variant() {
1908 let chunk = StreamChunk::Compaction("A summary".to_owned());
1909 assert_matches!(chunk, StreamChunk::Compaction(s) if s == "A summary");
1910 }
1911
1912 #[test]
1913 fn short_type_name_extracts_last_segment() {
1914 struct MyOutput;
1915 assert_eq!(short_type_name::<MyOutput>(), "MyOutput");
1916 }
1917
1918 #[test]
1919 fn short_type_name_primitive_returns_full_name() {
1920 assert_eq!(short_type_name::<u32>(), "u32");
1922 assert_eq!(short_type_name::<bool>(), "bool");
1923 }
1924
1925 #[test]
1926 fn short_type_name_nested_path_returns_last() {
1927 assert_eq!(
1929 short_type_name::<std::collections::HashMap<u32, u32>>(),
1930 "HashMap<u32, u32>"
1931 );
1932 }
1933
1934 #[test]
1937 fn summary_roundtrip() {
1938 let part = MessagePart::Summary {
1939 text: "hello".to_string(),
1940 };
1941 let json = serde_json::to_string(&part).expect("serialization must not fail");
1942 assert!(
1943 json.contains("\"kind\":\"summary\""),
1944 "must use internally-tagged format, got: {json}"
1945 );
1946 assert!(
1947 !json.contains("\"Summary\""),
1948 "must not use externally-tagged format, got: {json}"
1949 );
1950 let decoded: MessagePart =
1951 serde_json::from_str(&json).expect("deserialization must not fail");
1952 match decoded {
1953 MessagePart::Summary { text } => assert_eq!(text, "hello"),
1954 other => panic!("expected MessagePart::Summary, got {other:?}"),
1955 }
1956 }
1957
1958 #[tokio::test]
1959 async fn embed_batch_default_empty_returns_empty() {
1960 let provider = StubProvider {
1961 response: String::new(),
1962 };
1963 let result = provider.embed_batch(&[]).await.unwrap();
1964 assert!(result.is_empty());
1965 }
1966
1967 #[tokio::test]
1968 async fn embed_batch_default_calls_embed_sequentially() {
1969 let provider = StubProvider {
1970 response: String::new(),
1971 };
1972 let texts = ["hello", "world", "foo"];
1973 let result = provider.embed_batch(&texts).await.unwrap();
1974 assert_eq!(result.len(), 3);
1975 for vec in &result {
1977 assert_eq!(vec, &[0.1_f32, 0.2, 0.3]);
1978 }
1979 }
1980
1981 #[test]
1982 fn message_visibility_db_roundtrip_both() {
1983 assert_eq!(MessageVisibility::Both.as_db_str(), "both");
1984 assert_eq!(
1985 MessageVisibility::from_db_str("both"),
1986 MessageVisibility::Both
1987 );
1988 }
1989
1990 #[test]
1991 fn message_visibility_db_roundtrip_agent_only() {
1992 assert_eq!(MessageVisibility::AgentOnly.as_db_str(), "agent_only");
1993 assert_eq!(
1994 MessageVisibility::from_db_str("agent_only"),
1995 MessageVisibility::AgentOnly
1996 );
1997 }
1998
1999 #[test]
2000 fn message_visibility_db_roundtrip_user_only() {
2001 assert_eq!(MessageVisibility::UserOnly.as_db_str(), "user_only");
2002 assert_eq!(
2003 MessageVisibility::from_db_str("user_only"),
2004 MessageVisibility::UserOnly
2005 );
2006 }
2007
2008 #[test]
2009 fn message_visibility_from_db_str_unknown_defaults_to_both() {
2010 assert_eq!(
2011 MessageVisibility::from_db_str("unknown_future_value"),
2012 MessageVisibility::Both
2013 );
2014 assert_eq!(MessageVisibility::from_db_str(""), MessageVisibility::Both);
2015 }
2016}