1use std::collections::HashMap;
4use std::sync::{Arc, RwLock};
5
6use chrono::{DateTime, Utc};
7use reqwest::Method;
8use reqwest::multipart::Form;
9use serde_json::Value;
10
11use crate::error::{HonchoError, Result};
12use crate::http::client::HttpClient;
13use crate::http::routes;
14use crate::message::Message;
15use crate::types::message::MessageResponse;
16use crate::types::session::SessionResponse;
17use crate::types::session::{
18 SessionConfiguration, SessionConfigurationSet, SessionPeerConfig, SessionUpdate,
19};
20use crate::upload::FileSource;
21
22#[derive(Default)]
28struct SessionCacheState {
29 metadata: Option<HashMap<String, Value>>,
30 configuration: Option<SessionConfiguration>,
31 is_active: bool,
32}
33
34pub(crate) struct SessionInner {
35 http: HttpClient,
36 workspace_id: Arc<str>,
40 id: String,
41 cache: RwLock<SessionCacheState>,
42 created_at: DateTime<Utc>,
43}
44
45impl SessionInner {
46 fn read_lock(&self) -> std::sync::RwLockReadGuard<'_, SessionCacheState> {
48 self.cache
49 .read()
50 .unwrap_or_else(std::sync::PoisonError::into_inner)
51 }
52
53 fn write_lock(&self) -> std::sync::RwLockWriteGuard<'_, SessionCacheState> {
55 self.cache
56 .write()
57 .unwrap_or_else(std::sync::PoisonError::into_inner)
58 }
59
60 fn update_cache(&self, resp: &SessionResponse) {
62 let mut cache = self.write_lock();
63 cache.metadata = Some(resp.metadata.clone());
64 cache.configuration = Some(resp.configuration.clone());
65 cache.is_active = resp.is_active;
66 }
67}
68
69#[derive(Clone)]
74pub struct Session {
75 inner: Arc<SessionInner>,
76}
77
78#[non_exhaustive]
83#[derive(Debug, Clone)]
84pub enum PeerSpec {
85 Id(String),
87 WithConfig(String, SessionPeerConfig),
89}
90
91impl From<&str> for PeerSpec {
92 fn from(s: &str) -> Self {
93 Self::Id(s.to_owned())
94 }
95}
96
97impl From<String> for PeerSpec {
98 fn from(s: String) -> Self {
99 Self::Id(s)
100 }
101}
102
103impl From<&crate::Peer> for PeerSpec {
104 fn from(p: &crate::Peer) -> Self {
105 Self::Id(p.id().to_owned())
106 }
107}
108
109impl From<crate::Peer> for PeerSpec {
110 fn from(p: crate::Peer) -> Self {
111 Self::Id(p.id().to_owned())
112 }
113}
114
115impl From<(String, SessionPeerConfig)> for PeerSpec {
116 fn from((id, cfg): (String, SessionPeerConfig)) -> Self {
117 Self::WithConfig(id, cfg)
118 }
119}
120
121impl From<(&str, SessionPeerConfig)> for PeerSpec {
122 fn from((id, cfg): (&str, SessionPeerConfig)) -> Self {
123 Self::WithConfig(id.to_owned(), cfg)
124 }
125}
126
127impl From<(&crate::Peer, SessionPeerConfig)> for PeerSpec {
128 fn from((p, cfg): (&crate::Peer, SessionPeerConfig)) -> Self {
129 Self::WithConfig(p.id().to_owned(), cfg)
130 }
131}
132
133impl PeerSpec {
134 #[must_use]
140 pub fn into_parts(self) -> (String, SessionPeerConfig) {
141 match self {
142 Self::Id(id) => (id, SessionPeerConfig::default()),
143 Self::WithConfig(id, cfg) => (id, cfg),
144 }
145 }
146}
147
148#[must_use]
153pub struct UploadFileBuilder<'a> {
154 session: &'a Session,
155 source: Option<FileSource>,
156 peer_id: Option<String>,
157 metadata: Option<Value>,
158 configuration: Option<Value>,
159 created_at: Option<DateTime<Utc>>,
160}
161
162fn serialize_upload_fields(
163 builder: &UploadFileBuilder<'_>,
164) -> Result<impl Fn(Form) -> Form + Clone + Send + 'static> {
165 let metadata_text = builder
166 .metadata
167 .as_ref()
168 .map(|md| {
169 serde_json::to_string(md).map_err(|e| HonchoError::Serialization {
170 path: "MessageUploadFormMetadata".into(),
171 source: e,
172 })
173 })
174 .transpose()?;
175
176 let configuration_text = builder
177 .configuration
178 .as_ref()
179 .map(|cfg| {
180 serde_json::to_string(cfg).map_err(|e| HonchoError::Serialization {
181 path: "MessageUploadFormConfiguration".into(),
182 source: e,
183 })
184 })
185 .transpose()?;
186
187 let created_at_text = builder.created_at.map(|dt| dt.to_rfc3339());
188
189 Ok(move |mut form: Form| -> Form {
190 if let Some(ref md) = metadata_text {
191 form = form.text("metadata", md.clone());
192 }
193 if let Some(ref cfg) = configuration_text {
194 form = form.text("configuration", cfg.clone());
195 }
196 if let Some(ref dt) = created_at_text {
197 form = form.text("created_at", dt.clone());
198 }
199 form
200 })
201}
202
203fn derive_path_filename(path: &std::path::Path) -> Result<String> {
210 path.file_name()
211 .map(|n| n.to_string_lossy().into_owned())
212 .filter(|n| !n.is_empty())
213 .ok_or_else(|| {
214 HonchoError::Validation(format!(
215 "file source path has no file name component: {}",
216 path.display()
217 ))
218 })
219}
220
221fn build_form(
227 filename: String,
228 bytes: bytes::Bytes,
229 content_type: &str,
230 peer_id: String,
231 add_text_fields: impl Fn(Form) -> Form,
232) -> Result<Form> {
233 let mut headers = reqwest::header::HeaderMap::new();
234 let value = reqwest::header::HeaderValue::from_str(content_type)
235 .map_err(|_| HonchoError::Validation("invalid content_type".into()))?;
236 headers.insert(reqwest::header::CONTENT_TYPE, value);
237
238 let file_part = reqwest::multipart::Part::stream(reqwest::Body::from(bytes))
239 .file_name(filename)
240 .headers(headers);
241 let form = Form::new().part("file", file_part).text("peer_id", peer_id);
242 Ok(add_text_fields(form))
243}
244
245impl UploadFileBuilder<'_> {
246 pub fn peer(mut self, id: impl Into<String>) -> Self {
256 self.peer_id = Some(id.into());
257 self
258 }
259
260 pub fn metadata(mut self, value: Value) -> Self {
272 self.metadata = Some(value);
273 self
274 }
275
276 pub fn configuration(mut self, value: Value) -> Self {
288 self.configuration = Some(value);
289 self
290 }
291
292 pub fn created_at(mut self, dt: DateTime<Utc>) -> Self {
304 self.created_at = Some(dt);
305 self
306 }
307
308 #[cfg_attr(
328 feature = "tracing",
329 tracing::instrument(skip(self), name = "upload_file_send")
330 )]
331 pub async fn send(self) -> Result<Vec<crate::Message>> {
332 enum Resolved {
337 Path {
338 path: std::path::PathBuf,
339 filename: String,
340 },
341 Bytes {
342 filename: String,
343 bytes: bytes::Bytes,
344 content_type: String,
345 },
346 }
347 type FormFactory = Box<
348 dyn Fn() -> std::pin::Pin<
349 Box<dyn std::future::Future<Output = Result<Form>> + Send + 'static>,
350 > + Send
351 + 'static,
352 >;
353
354 let add_text_fields = serialize_upload_fields(&self)?;
355
356 let Some(peer_id) = self.peer_id else {
357 return Err(HonchoError::Validation("peer_id is required".into()));
358 };
359 let Some(source) = self.source else {
360 return Err(HonchoError::Validation("file source is required".into()));
361 };
362
363 let resolved = match source {
368 FileSource::Path(path) => {
369 let filename = derive_path_filename(&path)?;
372 Resolved::Path { path, filename }
373 }
374 FileSource::Bytes {
375 filename,
376 bytes,
377 content_type,
378 } => Resolved::Bytes {
379 filename,
380 bytes: bytes::Bytes::from(bytes),
385 content_type,
386 },
387 FileSource::Stream {
388 filename,
389 mut reader,
390 content_type,
391 } => {
392 let mut buf = Vec::new();
393 tokio::io::AsyncReadExt::read_to_end(&mut reader, &mut buf)
394 .await
395 .map_err(HonchoError::from)?;
396 Resolved::Bytes {
397 filename,
398 bytes: bytes::Bytes::from(buf),
399 content_type,
400 }
401 }
402 };
403
404 let form_factory: FormFactory = match resolved {
407 Resolved::Path { path, filename } => Box::new(move || {
408 let path = path.clone();
409 let filename = filename.clone();
410 let peer_id = peer_id.clone();
411 let add_text_fields = add_text_fields.clone();
412 Box::pin(async move {
413 let file_part = reqwest::multipart::Part::file(&path)
416 .await
417 .map_err(HonchoError::from)?
418 .file_name(filename);
419 let form = Form::new().part("file", file_part).text("peer_id", peer_id);
420 Ok(add_text_fields(form))
421 })
422 }),
423 Resolved::Bytes {
424 filename,
425 bytes,
426 content_type,
427 } => Box::new(move || {
428 let filename = filename.clone();
429 let bytes = bytes.clone();
430 let content_type = content_type.clone();
431 let peer_id = peer_id.clone();
432 let add_text_fields = add_text_fields.clone();
433 Box::pin(async move {
434 build_form(filename, bytes, &content_type, peer_id, add_text_fields)
435 })
436 }),
437 };
438
439 let route =
440 routes::messages_upload(&self.session.inner.workspace_id, &self.session.inner.id)?;
441
442 let responses: Vec<MessageResponse> = self
443 .session
444 .inner
445 .http
446 .post_multipart(&route, form_factory, &[])
447 .await?;
448
449 Ok(responses
450 .into_iter()
451 .map(crate::Message::from_raw)
452 .collect())
453 }
454}
455
456impl Session {
457 pub(crate) fn from_parts(
458 http: HttpClient,
459 workspace_id: String,
460 resp: SessionResponse,
461 ) -> Self {
462 Self {
463 inner: Arc::new(SessionInner {
464 http,
465 workspace_id: Arc::from(workspace_id),
466 id: resp.id,
467 cache: RwLock::new(SessionCacheState {
468 metadata: Some(resp.metadata),
469 configuration: Some(resp.configuration),
470 is_active: resp.is_active,
471 }),
472 created_at: resp.created_at,
473 }),
474 }
475 }
476
477 pub(crate) fn from_response(honcho: &crate::Honcho, resp: SessionResponse) -> Self {
478 Self::from_parts(
479 honcho.http().clone(),
480 honcho.workspace_id().to_owned(),
481 resp,
482 )
483 }
484
485 #[must_use]
495 pub fn id(&self) -> &str {
496 &self.inner.id
497 }
498
499 #[must_use]
511 pub fn is_active(&self) -> bool {
512 self.inner.read_lock().is_active
513 }
514
515 #[must_use]
527 pub fn metadata(&self) -> Option<HashMap<String, Value>> {
528 self.inner.read_lock().metadata.clone()
529 }
530
531 #[must_use]
543 pub fn configuration(&self) -> Option<SessionConfiguration> {
544 self.inner.read_lock().configuration.clone()
545 }
546
547 #[must_use]
557 pub fn created_at(&self) -> DateTime<Utc> {
558 self.inner.created_at
559 }
560
561 pub async fn refresh(&self) -> Result<()> {
574 self.refresh_into().await?;
575 Ok(())
576 }
577
578 async fn refresh_into(&self) -> Result<SessionResponse> {
588 let body = crate::types::session::SessionCreate {
594 id: self.inner.id.clone(),
595 metadata: None,
596 peers: None,
597 configuration: None,
598 };
599 let resp: SessionResponse = self
600 .inner
601 .http
602 .post(
603 &routes::sessions(&self.inner.workspace_id)?,
604 Some(&body),
605 &[],
606 )
607 .await?;
608 self.inner.update_cache(&resp);
609 Ok(resp)
610 }
611
612 pub async fn get_metadata(&self) -> Result<HashMap<String, Value>> {
623 let resp = self.refresh_into().await?;
624 Ok(resp.metadata)
625 }
626
627 pub async fn set_metadata(&self, metadata: HashMap<String, Value>) -> Result<()> {
640 let body = crate::types::session::SessionMetadataSet { metadata };
641 let resp: SessionResponse = self
642 .inner
643 .http
644 .put(
645 &routes::session(&self.inner.workspace_id, &self.inner.id)?,
646 Some(&body),
647 &[],
648 )
649 .await?;
650 self.inner.update_cache(&resp);
651 Ok(())
652 }
653
654 pub async fn get_configuration(&self) -> Result<SessionConfiguration> {
665 let resp = self.refresh_into().await?;
666 Ok(resp.configuration)
667 }
668
669 pub async fn set_configuration(&self, configuration: &SessionConfiguration) -> Result<()> {
682 let body = SessionUpdate {
683 metadata: None,
684 configuration: Some(configuration.clone()),
685 };
686 let resp: SessionResponse = self
687 .inner
688 .http
689 .put(
690 &routes::session(&self.inner.workspace_id, &self.inner.id)?,
691 Some(&body),
692 &[],
693 )
694 .await?;
695 self.inner.update_cache(&resp);
696 Ok(())
697 }
698
699 pub async fn get_configuration_raw(&self) -> Result<HashMap<String, Value>> {
705 let body = crate::types::session::SessionCreate {
706 id: self.inner.id.clone(),
707 metadata: None,
708 peers: None,
709 configuration: None,
710 };
711 let raw: serde_json::Value = self
712 .inner
713 .http
714 .post(
715 &routes::sessions(&self.inner.workspace_id)?,
716 Some(&body),
717 &[],
718 )
719 .await?;
720 match raw.get("configuration") {
721 Some(serde_json::Value::Object(map)) => {
722 Ok(map.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
723 }
724 _ => Ok(HashMap::new()),
725 }
726 }
727
728 pub async fn set_configuration_raw(&self, configuration: HashMap<String, Value>) -> Result<()> {
734 let body = SessionConfigurationSet { configuration };
735 let resp: SessionResponse = self
736 .inner
737 .http
738 .put(
739 &routes::session(&self.inner.workspace_id, &self.inner.id)?,
740 Some(&body),
741 &[],
742 )
743 .await?;
744 self.inner.update_cache(&resp);
745 Ok(())
746 }
747
748 pub async fn add_peer(&self, id: impl Into<String>) -> Result<()> {
761 self.add_peers(std::iter::once(PeerSpec::Id(id.into())))
762 .await
763 }
764
765 pub async fn add_peers(
776 &self,
777 specs: impl IntoIterator<Item = impl Into<PeerSpec>>,
778 ) -> Result<()> {
779 let peers_map = normalize_peers(specs)?;
780 let route = routes::session_peers(&self.inner.workspace_id, &self.inner.id)?;
781 self.inner.http.post(&route, Some(&peers_map), &[]).await
782 }
783
784 pub async fn set_peers(
795 &self,
796 specs: impl IntoIterator<Item = impl Into<PeerSpec>>,
797 ) -> Result<()> {
798 let peers_map = normalize_peers(specs)?;
799 let route = routes::session_peers(&self.inner.workspace_id, &self.inner.id)?;
800 self.inner.http.put(&route, Some(&peers_map), &[]).await
801 }
802
803 pub async fn remove_peers(
814 &self,
815 ids: impl IntoIterator<Item = impl Into<String>>,
816 ) -> Result<()> {
817 let id_list: Vec<String> = ids.into_iter().map(Into::into).collect();
818 let route = routes::session_peers(&self.inner.workspace_id, &self.inner.id)?;
819 self.inner
820 .http
821 .request::<_, ()>(Method::DELETE, &route, Some(&id_list), &[])
822 .await
823 }
824
825 pub async fn peers(&self) -> Result<Vec<crate::Peer>> {
844 use crate::types::pagination::PageResponse;
845
846 let route = routes::session_peers(&self.inner.workspace_id, &self.inner.id)?;
847 let mut all = Vec::new();
848 let mut page: u64 = 1;
849 loop {
850 let page_str = page.to_string();
851 let resp: PageResponse<crate::types::peer::Peer> = self
852 .inner
853 .http
854 .get(&route, &[("page", page_str.as_str())])
855 .await?;
856 let total_pages = resp.pages;
857 let was_empty = resp.items.is_empty();
858 for item in resp.items {
859 all.push(crate::Peer::from_parts(
860 self.inner.http.clone(),
861 self.inner.workspace_id.to_string(),
862 item,
863 )?);
864 }
865 if was_empty || page >= total_pages {
869 break;
870 }
871 page += 1;
872 }
873 Ok(all)
874 }
875
876 pub async fn get_peer_configuration(&self, peer_id: &str) -> Result<SessionPeerConfig> {
889 let route = routes::session_peer_config(&self.inner.workspace_id, &self.inner.id, peer_id)?;
890 self.inner.http.get(&route, &[]).await
891 }
892
893 pub async fn set_peer_configuration(
910 &self,
911 peer_id: &str,
912 config: &SessionPeerConfig,
913 ) -> Result<()> {
914 let route = routes::session_peer_config(&self.inner.workspace_id, &self.inner.id, peer_id)?;
915 self.inner.http.put(&route, Some(config), &[]).await
916 }
917
918 pub async fn add_messages(
940 &self,
941 messages: Vec<crate::types::message::MessageCreate>,
942 ) -> Result<Vec<Message>> {
943 if messages.is_empty() {
944 return Ok(Vec::new());
945 }
946
947 let route = routes::messages(&self.inner.workspace_id, &self.inner.id)?;
948
949 let responses: Vec<MessageResponse> = if messages.len() <= 100 {
950 let body = crate::types::message::MessageBatchCreate { messages };
951 self.inner.http.post(&route, Some(&body), &[]).await?
952 } else {
953 let mut all = Vec::with_capacity(messages.len());
954 let mut iter = messages.into_iter();
957 loop {
958 let batch: Vec<crate::types::message::MessageCreate> =
959 iter.by_ref().take(100).collect();
960 if batch.is_empty() {
961 break;
962 }
963 let body = crate::types::message::MessageBatchCreate { messages: batch };
964 match self
965 .inner
966 .http
967 .post::<_, Vec<MessageResponse>>(&route, Some(&body), &[])
968 .await
969 {
970 Ok(batch_responses) => all.extend(batch_responses),
971 Err(e) if all.is_empty() => return Err(e),
972 Err(e) => {
973 let sent = all.len();
974 let partial: Vec<Message> =
975 all.into_iter().map(Message::from_raw).collect();
976 return Err(HonchoError::PartialFailure {
977 messages: partial,
978 sent,
979 error: Box::new(e),
980 });
981 }
982 }
983 }
984 all
985 };
986
987 Ok(responses.into_iter().map(Message::from_raw).collect())
988 }
989
990 pub async fn messages(
1004 &self,
1005 ) -> Result<crate::types::pagination::Page<MessageResponse, Message>> {
1006 self.messages_with_options(None, 1, 50, false).await
1007 }
1008
1009 pub async fn messages_with_options(
1025 &self,
1026 filters: Option<HashMap<String, Value>>,
1027 page: u64,
1028 size: u64,
1029 reverse: bool,
1030 ) -> Result<crate::types::pagination::Page<MessageResponse, Message>> {
1031 let route = routes::messages_list(&self.inner.workspace_id, &self.inner.id)?;
1032 let body = filters
1033 .map(|f| {
1034 serde_json::to_value(f).map_err(|e| HonchoError::Serialization {
1035 path: "MessageGet".into(),
1036 source: e,
1037 })
1038 })
1039 .transpose()?;
1040 let result: crate::types::pagination::Page<MessageResponse> =
1041 crate::types::pagination::paginate_post(
1042 &self.inner.http,
1043 &route,
1044 body.as_ref(),
1045 page,
1046 size,
1047 reverse,
1048 )
1049 .await?;
1050 Ok(result.map(Message::from_raw))
1051 }
1052
1053 pub fn upload_file(&self, source: impl Into<FileSource>) -> UploadFileBuilder<'_> {
1073 UploadFileBuilder {
1074 session: self,
1075 source: Some(source.into()),
1076 peer_id: None,
1077 metadata: None,
1078 configuration: None,
1079 created_at: None,
1080 }
1081 }
1082
1083 pub fn upload_file_streamed(
1095 &self,
1096 filename: impl Into<String>,
1097 reader: impl tokio::io::AsyncRead + Send + 'static,
1098 content_type: impl Into<String>,
1099 ) -> UploadFileBuilder<'_> {
1100 UploadFileBuilder {
1101 session: self,
1102 source: Some(FileSource::stream(filename, reader, content_type)),
1103 peer_id: None,
1104 metadata: None,
1105 configuration: None,
1106 created_at: None,
1107 }
1108 }
1109
1110 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(session_id = self.inner.id.as_str())))]
1123 pub async fn delete(&self) -> Result<()> {
1124 self.inner
1125 .http
1126 .delete(
1127 &routes::session(&self.inner.workspace_id, &self.inner.id)?,
1128 &[],
1129 )
1130 .await
1131 }
1132
1133 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(session_id = self.inner.id.as_str())))]
1144 pub async fn clone_session(&self) -> Result<Session> {
1145 let route = routes::session_clone(&self.inner.workspace_id, &self.inner.id)?;
1146 let resp: SessionResponse = self.inner.http.post(&route, None::<&Value>, &[]).await?;
1147 Ok(Self::from_parts(
1148 self.inner.http.clone(),
1149 self.inner.workspace_id.to_string(),
1150 resp,
1151 ))
1152 }
1153
1154 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(session_id = self.inner.id.as_str())))]
1165 pub async fn clone_session_with_message(&self, message_id: &str) -> Result<Session> {
1166 let route = routes::session_clone(&self.inner.workspace_id, &self.inner.id)?;
1167 let resp: SessionResponse = self
1168 .inner
1169 .http
1170 .post(&route, None::<&Value>, &[("message_id", message_id)])
1171 .await?;
1172 Ok(Self::from_parts(
1173 self.inner.http.clone(),
1174 self.inner.workspace_id.to_string(),
1175 resp,
1176 ))
1177 }
1178
1179 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(session_id = self.inner.id.as_str())))]
1191 pub async fn get_message(&self, id: &str) -> Result<Message> {
1192 let route = routes::message(&self.inner.workspace_id, &self.inner.id, id)?;
1193 let resp: MessageResponse = self.inner.http.get(&route, &[]).await?;
1194 Ok(Message::from_raw(resp))
1195 }
1196
1197 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, metadata), fields(session_id = self.inner.id.as_str())))]
1210 pub async fn update_message(
1211 &self,
1212 id: &str,
1213 metadata: HashMap<String, Value>,
1214 ) -> Result<Message> {
1215 let route = routes::message(&self.inner.workspace_id, &self.inner.id, id)?;
1216 let body = crate::types::message::MessageMetadataSet { metadata };
1217 let resp: MessageResponse = self.inner.http.put(&route, Some(&body), &[]).await?;
1218 Ok(Message::from_raw(resp))
1219 }
1220
1221 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(session_id = self.inner.id.as_str())))]
1236 pub async fn context(&self) -> Result<crate::types::session::SessionContext> {
1237 self.context_builder().send().await
1238 }
1239
1240 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(session_id = self.inner.id.as_str())))]
1253 pub async fn context_with_options(
1254 &self,
1255 options: &crate::types::session::SessionContextOptions,
1256 ) -> Result<crate::types::session::SessionContext> {
1257 fetch_session_context(
1258 &self.inner.http,
1259 &self.inner.workspace_id,
1260 &self.inner.id,
1261 options,
1262 )
1263 .await
1264 }
1265
1266 pub fn context_builder(&self) -> SessionContextBuilder {
1283 SessionContextBuilder {
1284 http: self.inner.http.clone(),
1285 workspace_id: self.inner.workspace_id.to_string(),
1286 session_id: self.inner.id.clone(),
1287 summary: true,
1288 limit_to_session: false,
1289 tokens: None,
1290 peer_target: None,
1291 peer_perspective: None,
1292 search_query: None,
1293 search_top_k: None,
1294 search_max_distance: None,
1295 include_most_frequent: None,
1296 max_conclusions: None,
1297 }
1298 }
1299
1300 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(session_id = self.inner.id.as_str())))]
1316 pub async fn summaries(&self) -> Result<crate::types::session::SessionSummaries> {
1317 let route = routes::session_summaries(&self.inner.workspace_id, &self.inner.id)?;
1318 self.inner.http.get(&route, &[]).await
1319 }
1320
1321 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(session_id = self.inner.id.as_str())))]
1339 pub async fn search(&self, query: &str) -> Result<Vec<Message>> {
1340 self.search_with_options(&crate::types::message::MessageSearchOptions {
1341 query: query.to_string(),
1342 filters: None,
1343 limit: 10,
1344 })
1345 .await
1346 }
1347
1348 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, options), fields(session_id = self.inner.id.as_str())))]
1363 pub async fn search_with_options(
1364 &self,
1365 options: &crate::types::message::MessageSearchOptions,
1366 ) -> Result<Vec<Message>> {
1367 if options.query.is_empty() {
1368 return Err(crate::error::HonchoError::Validation(
1369 "query must not be empty".to_string(),
1370 ));
1371 }
1372 let route = routes::session_search(&self.inner.workspace_id, &self.inner.id)?;
1373 let responses: Vec<MessageResponse> =
1374 self.inner.http.post(&route, Some(&options), &[]).await?;
1375 Ok(responses.into_iter().map(Message::from_raw).collect())
1376 }
1377
1378 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(session_id = self.inner.id.as_str())))]
1392 pub async fn representation(&self, peer_id: &str) -> Result<String> {
1393 self.representation_builder(peer_id).send().await
1394 }
1395
1396 pub fn representation_builder(
1411 &self,
1412 peer_id: impl Into<String>,
1413 ) -> SessionRepresentationBuilder {
1414 SessionRepresentationBuilder {
1415 http: self.inner.http.clone(),
1416 workspace_id: self.inner.workspace_id.to_string(),
1417 session_id: self.inner.id.clone(),
1418 peer_id: peer_id.into(),
1419 target: None,
1420 search_query: None,
1421 search_top_k: None,
1422 search_max_distance: None,
1423 include_most_frequent: None,
1424 max_conclusions: None,
1425 }
1426 }
1427
1428 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(session_id = self.inner.id.as_str())))]
1439 pub async fn queue_status(
1440 &self,
1441 observer_id: Option<&str>,
1442 sender_id: Option<&str>,
1443 ) -> Result<crate::types::dream::QueueStatus> {
1444 let route = routes::workspace_queue_status(&self.inner.workspace_id)?;
1445 let mut query: Vec<(&str, &str)> = vec![("session_id", self.inner.id.as_str())];
1446 if let Some(v) = observer_id {
1447 query.push(("observer_id", v));
1448 }
1449 if let Some(v) = sender_id {
1450 query.push(("sender_id", v));
1451 }
1452 self.inner.http.get(&route, &query).await
1453 }
1454}
1455
1456#[must_use]
1458pub struct SessionRepresentationBuilder {
1459 http: HttpClient,
1460 workspace_id: String,
1461 session_id: String,
1462 peer_id: String,
1463 target: Option<String>,
1464 search_query: Option<String>,
1465 search_top_k: Option<u32>,
1466 search_max_distance: Option<f64>,
1467 include_most_frequent: Option<bool>,
1468 max_conclusions: Option<u32>,
1469}
1470
1471impl SessionRepresentationBuilder {
1472 pub fn target(mut self, val: impl Into<String>) -> Self {
1482 self.target = Some(val.into());
1483 self
1484 }
1485
1486 pub fn search_query(mut self, val: impl Into<String>) -> Self {
1496 self.search_query = Some(val.into());
1497 self
1498 }
1499
1500 pub fn search_top_k(mut self, val: u32) -> Self {
1510 self.search_top_k = Some(val);
1511 self
1512 }
1513
1514 pub fn search_max_distance(mut self, val: f64) -> Self {
1524 self.search_max_distance = Some(val);
1525 self
1526 }
1527
1528 pub fn include_most_frequent(mut self, val: bool) -> Self {
1538 self.include_most_frequent = Some(val);
1539 self
1540 }
1541
1542 pub fn max_conclusions(mut self, val: u32) -> Self {
1552 self.max_conclusions = Some(val);
1553 self
1554 }
1555
1556 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(session_id = self.session_id.as_str(), peer_id = self.peer_id.as_str())))]
1576 pub async fn send(self) -> Result<String> {
1577 crate::types::session::validate_search_params(
1578 self.search_top_k,
1579 self.search_max_distance,
1580 self.max_conclusions,
1581 )?;
1582
1583 let params = crate::types::peer::PeerRepresentationGet {
1584 session_id: Some(self.session_id),
1585 target: self.target,
1586 search_query: self.search_query,
1587 search_top_k: self.search_top_k,
1588 search_max_distance: self.search_max_distance,
1589 include_most_frequent: self.include_most_frequent,
1590 max_conclusions: self.max_conclusions,
1591 };
1592
1593 let route = routes::peer_representation(&self.workspace_id, &self.peer_id)?;
1594 let resp: crate::types::dialectic::RepresentationResponse =
1595 self.http.post(&route, Some(¶ms), &[]).await?;
1596 Ok(resp.representation)
1597 }
1598}
1599
1600#[must_use]
1619pub struct SessionContextBuilder {
1620 http: HttpClient,
1621 workspace_id: String,
1622 session_id: String,
1623 summary: bool,
1624 limit_to_session: bool,
1625 tokens: Option<u32>,
1626 peer_target: Option<String>,
1627 peer_perspective: Option<String>,
1628 search_query: Option<String>,
1629 search_top_k: Option<u32>,
1630 search_max_distance: Option<f64>,
1631 include_most_frequent: Option<bool>,
1632 max_conclusions: Option<u32>,
1633}
1634
1635impl SessionContextBuilder {
1636 pub fn summary(mut self, val: bool) -> Self {
1638 self.summary = val;
1639 self
1640 }
1641
1642 pub fn limit_to_session(mut self, val: bool) -> Self {
1644 self.limit_to_session = val;
1645 self
1646 }
1647
1648 pub fn tokens(mut self, val: u32) -> Self {
1650 self.tokens = Some(val);
1651 self
1652 }
1653
1654 pub fn peer_target(mut self, val: impl Into<String>) -> Self {
1656 self.peer_target = Some(val.into());
1657 self
1658 }
1659
1660 pub fn peer_perspective(mut self, val: impl Into<String>) -> Self {
1662 self.peer_perspective = Some(val.into());
1663 self
1664 }
1665
1666 pub fn search_query(mut self, val: impl Into<String>) -> Self {
1668 self.search_query = Some(val.into());
1669 self
1670 }
1671
1672 pub fn search_top_k(mut self, val: u32) -> Self {
1674 self.search_top_k = Some(val);
1675 self
1676 }
1677
1678 pub fn search_max_distance(mut self, val: f64) -> Self {
1680 self.search_max_distance = Some(val);
1681 self
1682 }
1683
1684 pub fn include_most_frequent(mut self, val: bool) -> Self {
1686 self.include_most_frequent = Some(val);
1687 self
1688 }
1689
1690 pub fn max_conclusions(mut self, val: u32) -> Self {
1692 self.max_conclusions = Some(val);
1693 self
1694 }
1695
1696 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(session_id = self.session_id.as_str())))]
1704 pub async fn send(self) -> Result<crate::types::session::SessionContext> {
1705 let options = crate::types::session::SessionContextOptions {
1706 summary: self.summary,
1707 limit_to_session: self.limit_to_session,
1708 tokens: self.tokens,
1709 peer_target: self.peer_target,
1710 peer_perspective: self.peer_perspective,
1711 search_query: self.search_query,
1712 search_top_k: self.search_top_k,
1713 search_max_distance: self.search_max_distance,
1714 include_most_frequent: self.include_most_frequent,
1715 max_conclusions: self.max_conclusions,
1716 };
1717 fetch_session_context(&self.http, &self.workspace_id, &self.session_id, &options).await
1718 }
1719}
1720
1721async fn fetch_session_context(
1725 http: &HttpClient,
1726 workspace_id: &str,
1727 session_id: &str,
1728 options: &crate::types::session::SessionContextOptions,
1729) -> Result<crate::types::session::SessionContext> {
1730 options.validate()?;
1731 let route = routes::session_context(workspace_id, session_id)?;
1732 let params = options.to_query_params();
1733 let refs: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, &**v)).collect();
1734 http.get(&route, &refs).await
1735}
1736
1737fn normalize_peers(
1738 specs: impl IntoIterator<Item = impl Into<PeerSpec>>,
1739) -> Result<serde_json::Value> {
1740 use serde_json::map::Entry;
1741
1742 let mut map = serde_json::Map::new();
1743 for s in specs {
1744 let (id, cfg) = s.into().into_parts();
1747 let val = serde_json::to_value(&cfg).map_err(|e| HonchoError::Serialization {
1748 path: "SessionPeerConfig".into(),
1749 source: e,
1750 })?;
1751 match map.entry(id) {
1755 Entry::Occupied(e) => {
1756 return Err(HonchoError::Validation(format!(
1757 "duplicate peer id: {}",
1758 e.key()
1759 )));
1760 }
1761 Entry::Vacant(e) => {
1762 e.insert(val);
1763 }
1764 }
1765 }
1766 Ok(Value::Object(map))
1767}
1768
1769#[cfg(test)]
1770mod tests {
1771 #![allow(clippy::unwrap_used, clippy::expect_used)]
1772
1773 use static_assertions::assert_impl_all;
1774
1775 use super::*;
1776 use crate::http::client::HttpClient;
1777 use crate::types::session::SessionResponse;
1778 use chrono::TimeZone;
1779 use wiremock::matchers::{body_string_contains, method, path};
1780 use wiremock::{Mock, MockServer, ResponseTemplate};
1781
1782 assert_impl_all!(UploadFileBuilder<'_>: Send);
1783
1784 fn session_json(id: &str) -> serde_json::Value {
1785 serde_json::json!({
1786 "id": id,
1787 "workspace_id": "ws1",
1788 "is_active": true,
1789 "metadata": {},
1790 "configuration": {},
1791 "created_at": "2025-01-15T10:30:00Z"
1792 })
1793 }
1794
1795 fn message_response_json(content: &str, peer_id: &str) -> serde_json::Value {
1796 serde_json::json!({
1797 "id": "msg_1",
1798 "content": content,
1799 "peer_id": peer_id,
1800 "session_id": "sess1",
1801 "metadata": {},
1802 "created_at": "2025-01-15T10:30:00Z",
1803 "workspace_id": "ws1",
1804 "token_count": 5
1805 })
1806 }
1807
1808 fn make_session(http: HttpClient, id: &str) -> Session {
1809 let resp: SessionResponse = serde_json::from_value(session_json(id)).unwrap();
1810 Session::from_parts(http, "ws1".to_owned(), resp)
1811 }
1812
1813 fn upload_response_json() -> serde_json::Value {
1814 serde_json::json!([message_response_json("extracted text", "alice")])
1815 }
1816
1817 #[tokio::test]
1818 async fn upload_file_with_bytes_sends_correct_multipart() {
1819 let server = MockServer::start().await;
1820 let http =
1821 HttpClient::from_params(HttpClient::builder().base_url(server.uri()).build()).unwrap();
1822 let session = make_session(http, "sess1");
1823
1824 Mock::given(method("POST"))
1825 .and(path("/v3/workspaces/ws1/sessions/sess1/messages/upload"))
1826 .and(body_string_contains("file content here"))
1827 .and(body_string_contains("peer_id"))
1828 .and(body_string_contains("alice"))
1829 .respond_with(ResponseTemplate::new(200).set_body_json(upload_response_json()))
1830 .mount(&server)
1831 .await;
1832
1833 let msgs = session
1834 .upload_file(FileSource::bytes(
1835 "test.txt",
1836 b"file content here".as_slice(),
1837 "text/plain",
1838 ))
1839 .peer("alice")
1840 .send()
1841 .await
1842 .unwrap();
1843
1844 assert_eq!(msgs.len(), 1);
1845 assert_eq!(msgs[0].content(), "extracted text");
1846 assert_eq!(msgs[0].peer_id(), "alice");
1847 }
1848
1849 #[tokio::test]
1850 async fn upload_file_with_metadata_sends_json_stringified_field() {
1851 let server = MockServer::start().await;
1852 let http =
1853 HttpClient::from_params(HttpClient::builder().base_url(server.uri()).build()).unwrap();
1854 let session = make_session(http, "sess1");
1855
1856 let metadata = serde_json::json!({"source": "upload", "priority": 1});
1857
1858 Mock::given(method("POST"))
1859 .and(path("/v3/workspaces/ws1/sessions/sess1/messages/upload"))
1860 .and(body_string_contains("\"source\":\"upload\""))
1861 .and(body_string_contains("\"priority\":1"))
1862 .respond_with(ResponseTemplate::new(200).set_body_json(upload_response_json()))
1863 .mount(&server)
1864 .await;
1865
1866 let msgs = session
1867 .upload_file(FileSource::bytes("f.txt", b"data", "text/plain"))
1868 .peer("alice")
1869 .metadata(metadata)
1870 .send()
1871 .await
1872 .unwrap();
1873
1874 assert_eq!(msgs.len(), 1);
1875 }
1876
1877 #[tokio::test]
1878 async fn upload_file_with_configuration_sends_json_stringified() {
1879 let server = MockServer::start().await;
1880 let http =
1881 HttpClient::from_params(HttpClient::builder().base_url(server.uri()).build()).unwrap();
1882 let session = make_session(http, "sess1");
1883
1884 let config = serde_json::json!({"reasoning": {"enabled": true}});
1885
1886 Mock::given(method("POST"))
1887 .and(path("/v3/workspaces/ws1/sessions/sess1/messages/upload"))
1888 .and(body_string_contains("\"reasoning\""))
1889 .and(body_string_contains("\"enabled\":true"))
1890 .respond_with(ResponseTemplate::new(200).set_body_json(upload_response_json()))
1891 .mount(&server)
1892 .await;
1893
1894 let msgs = session
1895 .upload_file(FileSource::bytes("f.txt", b"data", "text/plain"))
1896 .peer("bob")
1897 .configuration(config)
1898 .send()
1899 .await
1900 .unwrap();
1901
1902 assert_eq!(msgs.len(), 1);
1903 }
1904
1905 #[tokio::test]
1906 async fn upload_file_with_created_at_datetime_sends_iso_string() {
1907 let server = MockServer::start().await;
1908 let http =
1909 HttpClient::from_params(HttpClient::builder().base_url(server.uri()).build()).unwrap();
1910 let session = make_session(http, "sess1");
1911
1912 let dt = Utc.with_ymd_and_hms(2025, 3, 14, 9, 26, 53).unwrap();
1913
1914 Mock::given(method("POST"))
1915 .and(path("/v3/workspaces/ws1/sessions/sess1/messages/upload"))
1916 .and(body_string_contains("2025-03-14T09:26:53+00:00"))
1917 .respond_with(ResponseTemplate::new(200).set_body_json(upload_response_json()))
1918 .mount(&server)
1919 .await;
1920
1921 let msgs = session
1922 .upload_file(FileSource::bytes("f.txt", b"data", "text/plain"))
1923 .peer("alice")
1924 .created_at(dt)
1925 .send()
1926 .await
1927 .unwrap();
1928
1929 assert_eq!(msgs.len(), 1);
1930 }
1931
1932 #[tokio::test]
1933 async fn upload_file_with_path_reads_file_and_uploads() {
1934 let dir = tempfile::tempdir().unwrap();
1935 let file_path = dir.path().join("notes.txt");
1936 std::fs::write(&file_path, "file from disk").unwrap();
1937
1938 let server = MockServer::start().await;
1939 let http =
1940 HttpClient::from_params(HttpClient::builder().base_url(server.uri()).build()).unwrap();
1941 let session = make_session(http, "sess1");
1942
1943 Mock::given(method("POST"))
1944 .and(path("/v3/workspaces/ws1/sessions/sess1/messages/upload"))
1945 .and(body_string_contains("file from disk"))
1946 .respond_with(ResponseTemplate::new(200).set_body_json(upload_response_json()))
1947 .mount(&server)
1948 .await;
1949
1950 let msgs = session
1951 .upload_file(FileSource::path(&file_path))
1952 .peer("alice")
1953 .send()
1954 .await
1955 .unwrap();
1956
1957 assert_eq!(msgs.len(), 1);
1958 }
1959
1960 #[tokio::test]
1961 async fn upload_file_path_without_filename_returns_validation_error() {
1962 let server = MockServer::start().await;
1966 let http =
1967 HttpClient::from_params(HttpClient::builder().base_url(server.uri()).build()).unwrap();
1968 let session = make_session(http, "sess1");
1969
1970 let err = session
1971 .upload_file(FileSource::path("/"))
1972 .peer("alice")
1973 .send()
1974 .await
1975 .unwrap_err();
1976
1977 assert_eq!(err.code(), "validation_error");
1978 }
1979
1980 #[tokio::test]
1981 async fn upload_file_without_peer_returns_validation_error() {
1982 let server = MockServer::start().await;
1983 let http =
1984 HttpClient::from_params(HttpClient::builder().base_url(server.uri()).build()).unwrap();
1985 let session = make_session(http, "sess1");
1986
1987 let err = session
1988 .upload_file(FileSource::bytes("f.txt", b"data", "text/plain"))
1989 .send()
1990 .await
1991 .unwrap_err();
1992
1993 assert_eq!(err.code(), "validation_error");
1994 }
1995
1996 #[tokio::test]
1997 async fn upload_file_streamed_uses_reader_stream() {
1998 let server = MockServer::start().await;
1999 let http =
2000 HttpClient::from_params(HttpClient::builder().base_url(server.uri()).build()).unwrap();
2001 let session = make_session(http, "sess1");
2002
2003 Mock::given(method("POST"))
2004 .and(path("/v3/workspaces/ws1/sessions/sess1/messages/upload"))
2005 .and(body_string_contains("streamed payload"))
2006 .and(body_string_contains("peer_id"))
2007 .and(body_string_contains("carol"))
2008 .respond_with(ResponseTemplate::new(200).set_body_json(upload_response_json()))
2009 .mount(&server)
2010 .await;
2011
2012 let cursor = std::io::Cursor::new(b"streamed payload".to_vec());
2013 let msgs = session
2014 .upload_file_streamed("doc.txt", cursor, "text/plain")
2015 .peer("carol")
2016 .send()
2017 .await
2018 .unwrap();
2019
2020 assert_eq!(msgs.len(), 1);
2021 }
2022
2023 fn peer_json(id: &str) -> serde_json::Value {
2024 serde_json::json!({
2025 "id": id,
2026 "workspace_id": "ws1",
2027 "created_at": "2025-01-15T10:30:00Z",
2028 "metadata": {},
2029 "configuration": {}
2030 })
2031 }
2032
2033 #[tokio::test]
2034 async fn peers_traverses_all_pages() {
2035 use wiremock::matchers::query_param;
2036
2037 let server = MockServer::start().await;
2038 let http =
2039 HttpClient::from_params(HttpClient::builder().base_url(server.uri()).build()).unwrap();
2040 let session = make_session(http, "sess1");
2041
2042 Mock::given(method("GET"))
2043 .and(path("/v3/workspaces/ws1/sessions/sess1/peers"))
2044 .and(query_param("page", "1"))
2045 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2046 "items": [peer_json("alice")],
2047 "total": 2,
2048 "page": 1,
2049 "size": 1,
2050 "pages": 2
2051 })))
2052 .mount(&server)
2053 .await;
2054
2055 Mock::given(method("GET"))
2056 .and(path("/v3/workspaces/ws1/sessions/sess1/peers"))
2057 .and(query_param("page", "2"))
2058 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2059 "items": [peer_json("bob")],
2060 "total": 2,
2061 "page": 2,
2062 "size": 1,
2063 "pages": 2
2064 })))
2065 .mount(&server)
2066 .await;
2067
2068 let peers = session.peers().await.unwrap();
2069 assert_eq!(peers.len(), 2);
2070 assert_eq!(peers[0].id(), "alice");
2071 assert_eq!(peers[1].id(), "bob");
2072 }
2073
2074 #[tokio::test]
2075 async fn refresh_uses_get_or_create_and_updates_all_cache_fields() {
2076 let server = MockServer::start().await;
2077 let http =
2078 HttpClient::from_params(HttpClient::builder().base_url(server.uri()).build()).unwrap();
2079 let session = make_session(http, "sess1");
2080 assert!(session.is_active());
2081
2082 Mock::given(method("POST"))
2085 .and(path("/v3/workspaces/ws1/sessions"))
2086 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2087 "id": "sess1",
2088 "workspace_id": "ws1",
2089 "is_active": false,
2090 "metadata": {"topic": "fresh"},
2091 "configuration": {},
2092 "created_at": "2025-01-15T10:30:00Z"
2093 })))
2094 .mount(&server)
2095 .await;
2096
2097 session.refresh().await.unwrap();
2098 assert!(!session.is_active());
2099 assert_eq!(session.metadata().unwrap().get("topic").unwrap(), "fresh");
2100 }
2101
2102 #[tokio::test]
2106 async fn refresh_surfaces_server_404_as_not_found() {
2107 let server = MockServer::start().await;
2108 let http =
2109 HttpClient::from_params(HttpClient::builder().base_url(server.uri()).build()).unwrap();
2110 let session = make_session(http, "sess1");
2111
2112 Mock::given(method("POST"))
2113 .and(path("/v3/workspaces/ws1/sessions"))
2114 .respond_with(ResponseTemplate::new(404))
2115 .mount(&server)
2116 .await;
2117
2118 let err = session.refresh().await.unwrap_err();
2119 assert_eq!(err.code(), "not_found");
2120 }
2121
2122 #[tokio::test]
2123 async fn set_metadata_keeps_is_active_fresh() {
2124 let server = MockServer::start().await;
2125 let http =
2126 HttpClient::from_params(HttpClient::builder().base_url(server.uri()).build()).unwrap();
2127 let session = make_session(http, "sess1");
2128 assert!(session.is_active());
2129
2130 Mock::given(method("PUT"))
2133 .and(path("/v3/workspaces/ws1/sessions/sess1"))
2134 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2135 "id": "sess1",
2136 "workspace_id": "ws1",
2137 "is_active": false,
2138 "metadata": {"updated": true},
2139 "configuration": {},
2140 "created_at": "2025-01-15T10:30:00Z"
2141 })))
2142 .mount(&server)
2143 .await;
2144
2145 let mut meta = HashMap::new();
2146 meta.insert("updated".to_owned(), serde_json::json!(true));
2147 session.set_metadata(meta).await.unwrap();
2148
2149 assert!(!session.is_active());
2150 assert_eq!(session.metadata().unwrap().get("updated").unwrap(), true);
2151 }
2152
2153 #[tokio::test]
2154 async fn upload_invalid_content_type_returns_validation_error() {
2155 let server = MockServer::start().await;
2156 let http =
2157 HttpClient::from_params(HttpClient::builder().base_url(server.uri()).build()).unwrap();
2158 let session = make_session(http, "sess1");
2159
2160 let err = session
2161 .upload_file(FileSource::bytes("f.txt", b"data", "text/plain\n"))
2162 .peer("alice")
2163 .send()
2164 .await
2165 .unwrap_err();
2166
2167 assert_eq!(err.code(), "validation_error");
2168 }
2169
2170 #[tokio::test]
2171 async fn add_peers_duplicate_ids_returns_validation_error() {
2172 let server = MockServer::start().await;
2173 let http =
2174 HttpClient::from_params(HttpClient::builder().base_url(server.uri()).build()).unwrap();
2175 let session = make_session(http, "sess1");
2176
2177 let err = session.add_peers(["alice", "alice"]).await.unwrap_err();
2178 assert_eq!(err.code(), "validation_error");
2179 }
2180}