Skip to main content

honcho_ai/
session.rs

1//! Session wrapper — construction, metadata, peer management, per-peer config.
2
3use 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/// Single-lock cache of the server-owned session state.
23///
24/// Wrapping all mutable fields in one [`RwLock`] keeps them consistent: a
25/// refresh/PUT response updates every field under one write, so readers never
26/// observe a torn mix of fresh and stale fields.
27#[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    // Stored as `Arc<str>` so per-Message/Peer/Session clones are refcount bumps
37    // rather than allocations. Public method signatures keep taking `String`, so
38    // boundary conversions still allocate; this only removes the internal churn.
39    workspace_id: Arc<str>,
40    id: String,
41    cache: RwLock<SessionCacheState>,
42    created_at: DateTime<Utc>,
43}
44
45impl SessionInner {
46    /// Acquire the cache read lock, recovering from poisoning instead of panicking.
47    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    /// Acquire the cache write lock, recovering from poisoning instead of panicking.
54    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    /// Atomically replace every cached field from a fresh server response.
61    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/// A session in a Honcho workspace.
70///
71/// Wraps the API response and provides methods for metadata, configuration,
72/// peer management, messages, and more.
73#[derive(Clone)]
74pub struct Session {
75    inner: Arc<SessionInner>,
76}
77
78/// Specification for adding/setting peers on a session.
79///
80/// Use [`PeerSpec::Id`] for a bare peer ID or [`PeerSpec::WithConfig`] to
81/// include per-peer observation settings.
82#[non_exhaustive]
83#[derive(Debug, Clone)]
84pub enum PeerSpec {
85    /// A peer identified by ID with no explicit config.
86    Id(String),
87    /// A peer identified by ID with per-session configuration.
88    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    /// Decompose into `(peer_id, config)`.
135    ///
136    /// The bare-ID variant ([`PeerSpec::Id`]) yields a default
137    /// [`SessionPeerConfig`] (all observation settings unset), so callers can
138    /// treat both variants uniformly without re-matching.
139    #[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/// Builder for the file-upload operation returned by [`Session::upload_file`].
149///
150/// Call `.peer(id)` (required) and optionally chain `.metadata()`,
151/// `.configuration()`, `.created_at()` before calling `.send()`.
152#[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
203/// Derive the multipart filename for a `Path` upload, rejecting paths with no
204/// final file-name component.
205///
206/// A bare root (`/`) or a trailing `..` has no file name; uploading it would
207/// send an empty/absent filename. Surfacing it as [`HonchoError::Validation`]
208/// here lets the production upload path fail fast before any request is made.
209fn 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
221/// Build a multipart form for an in-memory (`Bytes`) upload payload.
222///
223/// The payload is a [`bytes::Bytes`], so each retry clones it as a cheap
224/// refcount bump rather than copying the buffer. An invalid `content_type`
225/// is surfaced as [`HonchoError::Validation`] instead of being silently dropped.
226fn 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    /// Set the peer that owns the uploaded file (required).
247    ///
248    /// # Examples
249    ///
250    /// ```no_run
251    /// # fn example(session: &honcho_ai::Session) {
252    /// let _builder = session.upload_file(honcho_ai::FileSource::bytes("f.txt", b"data", "text/plain")).peer("alice");
253    /// # }
254    /// ```
255    pub fn peer(mut self, id: impl Into<String>) -> Self {
256        self.peer_id = Some(id.into());
257        self
258    }
259
260    /// Attach arbitrary JSON metadata to the created message(s).
261    ///
262    /// # Examples
263    ///
264    /// ```no_run
265    /// # fn example(session: &honcho_ai::Session) {
266    /// let _builder = session.upload_file(honcho_ai::FileSource::bytes("f.txt", b"data", "text/plain"))
267    ///     .peer("alice")
268    ///     .metadata(serde_json::json!({"source": "upload"}));
269    /// # }
270    /// ```
271    pub fn metadata(mut self, value: Value) -> Self {
272        self.metadata = Some(value);
273        self
274    }
275
276    /// Attach configuration to the created message(s).
277    ///
278    /// # Examples
279    ///
280    /// ```no_run
281    /// # fn example(session: &honcho_ai::Session) {
282    /// let _builder = session.upload_file(honcho_ai::FileSource::bytes("f.txt", b"data", "text/plain"))
283    ///     .peer("alice")
284    ///     .configuration(serde_json::json!({"reasoning": true}));
285    /// # }
286    /// ```
287    pub fn configuration(mut self, value: Value) -> Self {
288        self.configuration = Some(value);
289        self
290    }
291
292    /// Override the creation timestamp (RFC 3339).
293    ///
294    /// # Examples
295    ///
296    /// ```no_run
297    /// # fn example(session: &honcho_ai::Session) {
298    /// let _builder = session.upload_file(honcho_ai::FileSource::bytes("f.txt", b"data", "text/plain"))
299    ///     .peer("alice")
300    ///     .created_at(chrono::Utc::now());
301    /// # }
302    /// ```
303    pub fn created_at(mut self, dt: DateTime<Utc>) -> Self {
304        self.created_at = Some(dt);
305        self
306    }
307
308    /// Resolve the file source, build the multipart form, POST, and return
309    /// the created messages.
310    ///
311    /// # Examples
312    ///
313    /// ```no_run
314    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
315    /// let msgs = session
316    ///     .upload_file(honcho_ai::FileSource::bytes("doc.pdf", b"data", "application/pdf"))
317    ///     .peer("alice")
318    ///     .send()
319    ///     .await?;
320    /// # Ok(())
321    /// # }
322    /// ```
323    ///
324    /// # Errors
325    ///
326    /// Returns [`HonchoError::Validation`] if no peer was set via `.peer()`.
327    #[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        // Local items are hoisted to the top of the scope (items-after-statements).
333        // `Resolved` normalizes the source to a single in-memory representation so
334        // the form-building match has only two arms; `FormFactory` is the per-retry
335        // form builder (see the usage sites below for the rationale).
336        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        // Normalize the source to a single in-memory representation: `Stream` is
364        // buffered into `Bytes` here so the form-building match has only two arms
365        // (disk-streamed `Path` vs. in-memory `Bytes`). The `Bytes` payload makes
366        // per-retry clones cheap refcount bumps instead of full buffer copies.
367        let resolved = match source {
368            FileSource::Path(path) => {
369                // Derive (and validate) the multipart filename up front so an
370                // unnamed path fails fast before any request is attempted.
371                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                // `FileSource::Bytes` carries a `Vec<u8>` (kept non-breaking in the
381                // public API). Convert it to `bytes::Bytes` exactly once here, before
382                // the retry closure is built, so each retry clones a cheap refcount
383                // handle (`Bytes::clone`) instead of copying the whole buffer.
384                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        // FileSource::Path — Part::file() streams from disk, re-opens on retry.
405        // Resolved::Bytes — in-memory payload, cheap to clone per retry.
406        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                    // `Part::file` streams the body straight from disk (re-opened
414                    // on each retry) — the file is never buffered into memory.
415                    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    /// The session's unique identifier.
486    ///
487    /// # Examples
488    ///
489    /// ```no_run
490    /// # fn example(session: &honcho_ai::Session) {
491    /// println!("{}", session.id());
492    /// # }
493    /// ```
494    #[must_use]
495    pub fn id(&self) -> &str {
496        &self.inner.id
497    }
498
499    /// Whether the session is currently active.
500    ///
501    /// # Examples
502    ///
503    /// ```no_run
504    /// # fn example(session: &honcho_ai::Session) {
505    /// if session.is_active() {
506    ///     println!("session is active");
507    /// }
508    /// # }
509    /// ```
510    #[must_use]
511    pub fn is_active(&self) -> bool {
512        self.inner.read_lock().is_active
513    }
514
515    /// Cached metadata from the last API response.
516    ///
517    /// # Examples
518    ///
519    /// ```no_run
520    /// # fn example(session: &honcho_ai::Session) {
521    /// if let Some(meta) = session.metadata() {
522    ///     println!("{meta:?}");
523    /// }
524    /// # }
525    /// ```
526    #[must_use]
527    pub fn metadata(&self) -> Option<HashMap<String, Value>> {
528        self.inner.read_lock().metadata.clone()
529    }
530
531    /// Cached configuration from the last API response.
532    ///
533    /// # Examples
534    ///
535    /// ```no_run
536    /// # fn example(session: &honcho_ai::Session) {
537    /// if let Some(config) = session.configuration() {
538    ///     println!("{config:?}");
539    /// }
540    /// # }
541    /// ```
542    #[must_use]
543    pub fn configuration(&self) -> Option<SessionConfiguration> {
544        self.inner.read_lock().configuration.clone()
545    }
546
547    /// When the session was created.
548    ///
549    /// # Examples
550    ///
551    /// ```no_run
552    /// # fn example(session: &honcho_ai::Session) {
553    /// println!("{}", session.created_at());
554    /// # }
555    /// ```
556    #[must_use]
557    pub fn created_at(&self) -> DateTime<Utc> {
558        self.inner.created_at
559    }
560
561    // ── F6.1: Refresh / Metadata / Configuration CRUD ──────────────────
562
563    /// Refresh the session's cached metadata and configuration from the server.
564    ///
565    /// # Examples
566    ///
567    /// ```no_run
568    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
569    /// session.refresh().await?;
570    /// # Ok(())
571    /// # }
572    /// ```
573    pub async fn refresh(&self) -> Result<()> {
574        self.refresh_into().await?;
575        Ok(())
576    }
577
578    /// Re-fetch the current session state, refresh the cache, and return the
579    /// response.
580    ///
581    /// The server exposes no `GET /sessions/{id}` (it answers `405`), so this
582    /// reads through the get-or-create `POST /sessions` collection endpoint.
583    /// One consequence: a session deleted server-side is silently re-created
584    /// rather than surfacing as `NotFound`. Callers that need the fresh metadata
585    /// or configuration should read it from the returned response to avoid a
586    /// refresh-then-read race against concurrent writers.
587    async fn refresh_into(&self) -> Result<SessionResponse> {
588        // The individual-resource path (`GET /v3/workspaces/{ws}/sessions/{id}`)
589        // is not exposed by the server — only `PUT`/`DELETE` are — so reads go
590        // through the collection get-or-create, which returns the full session
591        // without mutating it: `SessionCreate` skips its `None` fields, so the
592        // request body is just `{"id": …}`.
593        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    /// Fetch and return the session's metadata, updating the cache.
613    ///
614    /// # Examples
615    ///
616    /// ```no_run
617    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
618    /// let meta = session.get_metadata().await?;
619    /// # Ok(())
620    /// # }
621    /// ```
622    pub async fn get_metadata(&self) -> Result<HashMap<String, Value>> {
623        let resp = self.refresh_into().await?;
624        Ok(resp.metadata)
625    }
626
627    /// Set session metadata on the server and update the cache.
628    ///
629    /// # Examples
630    ///
631    /// ```no_run
632    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
633    /// let mut meta = std::collections::HashMap::new();
634    /// meta.insert("topic".into(), "rust".into());
635    /// session.set_metadata(meta).await?;
636    /// # Ok(())
637    /// # }
638    /// ```
639    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    /// Fetch and return session configuration, updating the cache.
655    ///
656    /// # Examples
657    ///
658    /// ```no_run
659    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
660    /// let config = session.get_configuration().await?;
661    /// # Ok(())
662    /// # }
663    /// ```
664    pub async fn get_configuration(&self) -> Result<SessionConfiguration> {
665        let resp = self.refresh_into().await?;
666        Ok(resp.configuration)
667    }
668
669    /// Set session configuration on the server and update the cache.
670    ///
671    /// # Examples
672    ///
673    /// ```no_run
674    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
675    /// use honcho_ai::types::session::SessionConfiguration;
676    /// let config = SessionConfiguration::default();
677    /// session.set_configuration(&config).await?;
678    /// # Ok(())
679    /// # }
680    /// ```
681    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    /// Fetch session configuration as a raw JSON map.
700    ///
701    /// Prefer [`get_configuration`](Self::get_configuration) for typed access.
702    /// Use this when the server returns fields not yet represented in
703    /// [`SessionConfiguration`].
704    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    /// Set session configuration from a raw JSON map.
729    ///
730    /// Prefer [`set_configuration`](Self::set_configuration) for typed access.
731    /// Use this when you need to send fields not yet represented in
732    /// [`SessionConfiguration`].
733    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    // ── F6.2: Peer Management ──────────────────────────────────────────
749
750    /// Add a single peer to this session.
751    ///
752    /// # Examples
753    ///
754    /// ```no_run
755    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
756    /// session.add_peer("alice").await?;
757    /// # Ok(())
758    /// # }
759    /// ```
760    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    /// Add multiple peers to this session.
766    ///
767    /// # Examples
768    ///
769    /// ```no_run
770    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
771    /// session.add_peers(["alice", "bob"]).await?;
772    /// # Ok(())
773    /// # }
774    /// ```
775    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    /// Set the complete peer list for this session (replaces existing).
785    ///
786    /// # Examples
787    ///
788    /// ```no_run
789    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
790    /// session.set_peers(["alice", "bob"]).await?;
791    /// # Ok(())
792    /// # }
793    /// ```
794    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    /// Remove peers from this session.
804    ///
805    /// # Examples
806    ///
807    /// ```no_run
808    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
809    /// session.remove_peers(["bob"]).await?;
810    /// # Ok(())
811    /// # }
812    /// ```
813    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    /// List peers in this session.
826    ///
827    /// This call is **all-or-nothing**: it walks every page and deserializes each
828    /// peer. If any single peer fails to deserialize (`Peer::from_parts` errors),
829    /// the whole call returns that error and the peers already accumulated from
830    /// earlier pages are discarded — partial results are never returned.
831    ///
832    /// # Examples
833    ///
834    /// ```no_run
835    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
836    /// let peers = session.peers().await?;
837    /// for p in &peers {
838    ///     println!("{}", p.id());
839    /// }
840    /// # Ok(())
841    /// # }
842    /// ```
843    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            // Stop once we have walked every page. `was_empty` guards against a
866            // server reporting a page count larger than the items it returns,
867            // which would otherwise loop forever.
868            if was_empty || page >= total_pages {
869                break;
870            }
871            page += 1;
872        }
873        Ok(all)
874    }
875
876    // ── F6.3: Per-peer configuration ───────────────────────────────────
877
878    /// Get per-peer configuration for a specific peer in this session.
879    ///
880    /// # Examples
881    ///
882    /// ```no_run
883    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
884    /// let config = session.get_peer_configuration("alice").await?;
885    /// # Ok(())
886    /// # }
887    /// ```
888    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    /// Set per-peer configuration for a specific peer in this session.
894    ///
895    /// The peer must already be present in the session. This method does not
896    /// create or add peers; use [`Session::add_peer`] or [`Session::add_peers`]
897    /// first. If the peer is absent, the server may return 404/`NotFound`.
898    ///
899    /// # Examples
900    ///
901    /// ```ignore
902    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
903    /// use honcho_ai::types::session::SessionPeerConfig;
904    /// let config = SessionPeerConfig { observe_me: Some(true), observe_others: Some(false) };
905    /// session.set_peer_configuration("alice", &config).await?;
906    /// # Ok(())
907    /// # }
908    /// ```
909    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    // ── F6.4: Messages ─────────────────────────────────────────────────
919
920    /// Add messages to this session.
921    ///
922    /// If more than 100 messages are provided, they are automatically chunked
923    /// into batches of 100 and sent as separate requests. On chunk failure the
924    /// already-sent messages are **not** rolled back (non-atomic). When a chunk
925    /// fails after earlier chunks succeeded, the error is a
926    /// [`HonchoError::PartialFailure`] containing the successfully created
927    /// messages from the earlier chunks.
928    ///
929    /// # Examples
930    ///
931    /// ```no_run
932    /// # async fn example(client: &honcho_ai::Honcho, session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
933    /// let peer = client.peer("alice").build().await?;
934    /// let msg = peer.message("Hello!").build()?;
935    /// let messages = session.add_messages(vec![msg]).await?;
936    /// # Ok(())
937    /// # }
938    /// ```
939    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            // Drain owned messages 100 at a time instead of cloning each chunk:
955            // `by_ref().take(100)` moves each batch out of the iterator.
956            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    /// List messages in this session with default pagination (no filters, page 1, size 50).
991    ///
992    /// # Examples
993    ///
994    /// ```no_run
995    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
996    /// let page = session.messages().await?;
997    /// for msg in page.items() {
998    ///     println!("{}", msg.content());
999    /// }
1000    /// # Ok(())
1001    /// # }
1002    /// ```
1003    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    /// List messages in this session with optional filters, page, size, and reverse.
1010    ///
1011    /// `page` is 1-based. `size` must be in `1..=100`.
1012    ///
1013    /// # Examples
1014    ///
1015    /// ```no_run
1016    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
1017    /// let page = session.messages_with_options(None, 1, 25, false).await?;
1018    /// for msg in page.items() {
1019    ///     println!("{}", msg.content());
1020    /// }
1021    /// # Ok(())
1022    /// # }
1023    /// ```
1024    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    // ── F7.3: File upload ───────────────────────────────────────────────
1054
1055    /// Begin a file upload to this session.
1056    ///
1057    /// The API currently accepts `text/plain`, `application/pdf`, and
1058    /// `application/json`; other MIME types may be rejected by the server.
1059    ///
1060    /// Returns an [`UploadFileBuilder`]. You **must** call `.peer(id)` and
1061    /// then `.send()` to complete the upload.
1062    ///
1063    /// # Example
1064    ///
1065    /// ```ignore
1066    /// let messages = session
1067    ///     .upload_file(FileSource::bytes("doc.pdf", data, "application/pdf"))
1068    ///     .peer("alice")
1069    ///     .send()
1070    ///     .await?;
1071    /// ```
1072    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    /// Begin a file upload to this session from a streaming reader.
1084    ///
1085    /// The API currently accepts `text/plain`, `application/pdf`, and
1086    /// `application/json`; other MIME types may be rejected by the server.
1087    ///
1088    /// The reader is fully buffered into memory before uploading. This is
1089    /// **not** true streaming — use [`Session::upload_file`] with a
1090    /// [`FileSource::path`] for filesystem streaming that avoids buffering.
1091    ///
1092    /// Returns an [`UploadFileBuilder`]. You **must** call `.peer(id)` and
1093    /// then `.send()` to complete the upload.
1094    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    // ── F6.5: Delete, clone, get/update message ────────────────────────
1111
1112    /// Delete this session.
1113    ///
1114    /// # Examples
1115    ///
1116    /// ```no_run
1117    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
1118    /// session.delete().await?;
1119    /// # Ok(())
1120    /// # }
1121    /// ```
1122    #[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    /// Clone this session, returning a new `Session`.
1134    ///
1135    /// # Examples
1136    ///
1137    /// ```no_run
1138    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
1139    /// let cloned = session.clone_session().await?;
1140    /// # Ok(())
1141    /// # }
1142    /// ```
1143    #[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    /// Clone this session up to (and including) the given message.
1155    ///
1156    /// # Examples
1157    ///
1158    /// ```no_run
1159    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
1160    /// let cloned = session.clone_session_with_message("msg-42").await?;
1161    /// # Ok(())
1162    /// # }
1163    /// ```
1164    #[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    /// Get a single message by ID.
1180    ///
1181    /// # Examples
1182    ///
1183    /// ```no_run
1184    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
1185    /// let msg = session.get_message("msg-1").await?;
1186    /// println!("{}", msg.content());
1187    /// # Ok(())
1188    /// # }
1189    /// ```
1190    #[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    /// Update a message's metadata.
1198    ///
1199    /// # Examples
1200    ///
1201    /// ```no_run
1202    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
1203    /// let mut meta = std::collections::HashMap::new();
1204    /// meta.insert("edited".into(), true.into());
1205    /// let msg = session.update_message("msg-1", meta).await?;
1206    /// # Ok(())
1207    /// # }
1208    /// ```
1209    #[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    // ── F6.6: Context ───────────────────────────────────────────────────
1222
1223    /// Get the session context with default parameters.
1224    ///
1225    /// Fetches messages, summary, peer representation, and peer card for this session.
1226    ///
1227    /// # Examples
1228    ///
1229    /// ```no_run
1230    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
1231    /// let ctx = session.context().await?;
1232    /// # Ok(())
1233    /// # }
1234    /// ```
1235    #[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    /// Get the session context with custom parameters.
1241    ///
1242    /// # Examples
1243    ///
1244    /// ```no_run
1245    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
1246    /// use honcho_ai::types::session::SessionContextOptions;
1247    /// let opts = SessionContextOptions::builder().summary(true).build();
1248    /// let ctx = session.context_with_options(&opts).await?;
1249    /// # Ok(())
1250    /// # }
1251    /// ```
1252    #[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    /// Get a context builder for fine-grained control over session context parameters.
1267    ///
1268    /// # Examples
1269    ///
1270    /// ```no_run
1271    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
1272    /// let ctx = session.context_builder()
1273    ///     .summary(true)
1274    ///     .peer_target("alice")
1275    ///     .search_query("preferences")
1276    ///     .search_top_k(10)
1277    ///     .send()
1278    ///     .await?;
1279    /// # Ok(())
1280    /// # }
1281    /// ```
1282    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    // ── F6.8: Summaries ─────────────────────────────────────────────────
1301
1302    /// Get available summaries for this session.
1303    ///
1304    /// Returns both short and long summaries if they are available.
1305    /// Summaries are created asynchronously as messages are added.
1306    ///
1307    /// # Examples
1308    ///
1309    /// ```no_run
1310    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
1311    /// let summaries = session.summaries().await?;
1312    /// # Ok(())
1313    /// # }
1314    /// ```
1315    #[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    // ── F6.9: Search, representation, queue_status ──────────────────────
1322
1323    /// Search messages within this session (default limit of 10).
1324    ///
1325    /// Returns `Err(HonchoError::Validation)` when `query` is empty.
1326    ///
1327    /// # Examples
1328    ///
1329    /// ```no_run
1330    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
1331    /// let results = session.search("important topic").await?;
1332    /// for msg in results {
1333    ///     println!("{}", msg.content());
1334    /// }
1335    /// # Ok(())
1336    /// # }
1337    /// ```
1338    #[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    /// Search messages within this session with custom options (limit, filters).
1349    ///
1350    /// Returns `Err(HonchoError::Validation)` when `query` is empty.
1351    ///
1352    /// # Examples
1353    ///
1354    /// ```no_run
1355    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
1356    /// use honcho_ai::types::message::MessageSearchOptions;
1357    /// let opts = MessageSearchOptions::builder().query("topic").limit(20).build();
1358    /// let results = session.search_with_options(&opts).await?;
1359    /// # Ok(())
1360    /// # }
1361    /// ```
1362    #[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    /// Get a peer's representation scoped to this session.
1379    ///
1380    /// Uses the peer representation endpoint with `session_id` filter.
1381    ///
1382    /// # Examples
1383    ///
1384    /// ```no_run
1385    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
1386    /// let rep = session.representation("alice").await?;
1387    /// println!("{rep}");
1388    /// # Ok(())
1389    /// # }
1390    /// ```
1391    #[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    /// Create a builder for fine-grained representation requests scoped to this session.
1397    ///
1398    /// # Examples
1399    ///
1400    /// ```no_run
1401    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
1402    /// let rep = session.representation_builder("alice")
1403    ///     .search_query("hobbies")
1404    ///     .search_top_k(10)
1405    ///     .send()
1406    ///     .await?;
1407    /// # Ok(())
1408    /// # }
1409    /// ```
1410    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    /// Get the processing queue status for this session.
1429    ///
1430    /// # Examples
1431    ///
1432    /// ```no_run
1433    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
1434    /// let status = session.queue_status(None, None).await?;
1435    /// # Ok(())
1436    /// # }
1437    /// ```
1438    #[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/// Builder for fine-grained representation requests scoped to a session.
1457#[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    /// Get the representation for a specific target peer.
1473    ///
1474    /// # Examples
1475    ///
1476    /// ```no_run
1477    /// # fn example(session: &honcho_ai::Session) {
1478    /// let _builder = session.representation_builder("alice").target("bob");
1479    /// # }
1480    /// ```
1481    pub fn target(mut self, val: impl Into<String>) -> Self {
1482        self.target = Some(val.into());
1483        self
1484    }
1485
1486    /// Semantic search query to curate the representation.
1487    ///
1488    /// # Examples
1489    ///
1490    /// ```no_run
1491    /// # fn example(session: &honcho_ai::Session) {
1492    /// let _builder = session.representation_builder("alice").search_query("hobbies");
1493    /// # }
1494    /// ```
1495    pub fn search_query(mut self, val: impl Into<String>) -> Self {
1496        self.search_query = Some(val.into());
1497        self
1498    }
1499
1500    /// Number of semantic-search-retrieved conclusions (1–100).
1501    ///
1502    /// # Examples
1503    ///
1504    /// ```no_run
1505    /// # fn example(session: &honcho_ai::Session) {
1506    /// let _builder = session.representation_builder("alice").search_top_k(20);
1507    /// # }
1508    /// ```
1509    pub fn search_top_k(mut self, val: u32) -> Self {
1510        self.search_top_k = Some(val);
1511        self
1512    }
1513
1514    /// Maximum distance for semantically relevant conclusions (0.0–1.0).
1515    ///
1516    /// # Examples
1517    ///
1518    /// ```no_run
1519    /// # fn example(session: &honcho_ai::Session) {
1520    /// let _builder = session.representation_builder("alice").search_max_distance(0.5);
1521    /// # }
1522    /// ```
1523    pub fn search_max_distance(mut self, val: f64) -> Self {
1524        self.search_max_distance = Some(val);
1525        self
1526    }
1527
1528    /// Whether to include the most frequent conclusions.
1529    ///
1530    /// # Examples
1531    ///
1532    /// ```no_run
1533    /// # fn example(session: &honcho_ai::Session) {
1534    /// let _builder = session.representation_builder("alice").include_most_frequent(true);
1535    /// # }
1536    /// ```
1537    pub fn include_most_frequent(mut self, val: bool) -> Self {
1538        self.include_most_frequent = Some(val);
1539        self
1540    }
1541
1542    /// Maximum number of conclusions to include (1–100).
1543    ///
1544    /// # Examples
1545    ///
1546    /// ```no_run
1547    /// # fn example(session: &honcho_ai::Session) {
1548    /// let _builder = session.representation_builder("alice").max_conclusions(25);
1549    /// # }
1550    /// ```
1551    pub fn max_conclusions(mut self, val: u32) -> Self {
1552        self.max_conclusions = Some(val);
1553        self
1554    }
1555
1556    /// Send the representation request with the configured parameters.
1557    ///
1558    /// # Examples
1559    ///
1560    /// ```no_run
1561    /// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
1562    /// let rep = session.representation_builder("alice")
1563    ///     .search_query("hobbies")
1564    ///     .search_top_k(10)
1565    ///     .send()
1566    ///     .await?;
1567    /// # Ok(())
1568    /// # }
1569    /// ```
1570    ///
1571    /// # Errors
1572    ///
1573    /// Returns `HonchoError::Validation` if `search_top_k`, `search_max_distance`,
1574    /// or `max_conclusions` are out of range.
1575    #[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(&params), &[]).await?;
1596        Ok(resp.representation)
1597    }
1598}
1599
1600/// Builder for fine-grained session context requests.
1601///
1602/// Created via [`Session::context_builder()`].
1603///
1604/// # Examples
1605///
1606/// ```no_run
1607/// # async fn example(session: &honcho_ai::Session) -> honcho_ai::error::Result<()> {
1608/// let ctx = session.context_builder()
1609///     .summary(true)
1610///     .peer_target("alice")
1611///     .search_query("preferences")
1612///     .search_top_k(10)
1613///     .send()
1614///     .await?;
1615/// # Ok(())
1616/// # }
1617/// ```
1618#[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    /// Whether to include summaries (default: `true`).
1637    pub fn summary(mut self, val: bool) -> Self {
1638        self.summary = val;
1639        self
1640    }
1641
1642    /// Limit context to this session only (default: `false`).
1643    pub fn limit_to_session(mut self, val: bool) -> Self {
1644        self.limit_to_session = val;
1645        self
1646    }
1647
1648    /// Maximum number of tokens for the context.
1649    pub fn tokens(mut self, val: u32) -> Self {
1650        self.tokens = Some(val);
1651        self
1652    }
1653
1654    /// Target peer for perspective-based context.
1655    pub fn peer_target(mut self, val: impl Into<String>) -> Self {
1656        self.peer_target = Some(val.into());
1657        self
1658    }
1659
1660    /// Perspective peer for viewing context.
1661    pub fn peer_perspective(mut self, val: impl Into<String>) -> Self {
1662        self.peer_perspective = Some(val.into());
1663        self
1664    }
1665
1666    /// Semantic search query to filter relevant conclusions.
1667    pub fn search_query(mut self, val: impl Into<String>) -> Self {
1668        self.search_query = Some(val.into());
1669        self
1670    }
1671
1672    /// Number of semantic-search-retrieved conclusions (1–100).
1673    pub fn search_top_k(mut self, val: u32) -> Self {
1674        self.search_top_k = Some(val);
1675        self
1676    }
1677
1678    /// Maximum distance for semantically relevant conclusions (0.0–1.0).
1679    pub fn search_max_distance(mut self, val: f64) -> Self {
1680        self.search_max_distance = Some(val);
1681        self
1682    }
1683
1684    /// Whether to include the most frequent conclusions.
1685    pub fn include_most_frequent(mut self, val: bool) -> Self {
1686        self.include_most_frequent = Some(val);
1687        self
1688    }
1689
1690    /// Maximum number of conclusions to include (1–100).
1691    pub fn max_conclusions(mut self, val: u32) -> Self {
1692        self.max_conclusions = Some(val);
1693        self
1694    }
1695
1696    /// Send the context request with the configured parameters.
1697    ///
1698    /// # Errors
1699    ///
1700    /// Returns `HonchoError::Validation` if `search_top_k`, `search_max_distance`,
1701    /// or `max_conclusions` are out of range, if `peer_perspective` is set without
1702    /// `peer_target`, or if `search_query` is set without `peer_target`.
1703    #[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
1721/// Validate options, then GET the session context endpoint with the query
1722/// parameters they produce. Shared by [`Session::context_with_options`] and
1723/// [`SessionContextBuilder::send`] so the route/query/GET logic lives once.
1724async 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        // Decompose by value: the id is owned (no extra clone) and the config is
1745        // taken directly, defaulting for the bare-ID variant.
1746        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        // Reject duplicate IDs instead of letting a later entry silently clobber
1752        // an earlier one. The Entry API does a single lookup for both the
1753        // duplicate check and the insert.
1754        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        // Regression: a path with no final file-name component (`/`, trailing
1963        // `..`) must fail fast on the PRODUCTION upload path before any request
1964        // is made, rather than silently uploading with an empty filename.
1965        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        // No `GET /sessions/{id}` exists server-side; reads go through the
2083        // get-or-create `POST /sessions` collection endpoint.
2084        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    // A real get-or-create `POST /sessions` re-creates a missing session (200),
2103    // so "deleted session" never yields 404 in practice; this just verifies the
2104    // SDK surfaces a server 404 as `NotFound` rather than swallowing it.
2105    #[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        // The server flips is_active in its PUT response: the single-lock cache
2131        // must refresh is_active too, not just metadata.
2132        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}