Skip to main content

claude_api/managed_agents/
sessions.rs

1//! Sessions: provision, retrieve, list, archive, delete.
2//!
3//! A session is a running agent instance within an environment. Each
4//! session references an [agent](super::agents) (by ID or pinned to a
5//! version) and an environment, and maintains conversation history
6//! across multiple interactions.
7
8use std::collections::HashMap;
9
10use serde::{Deserialize, Serialize};
11
12use crate::client::Client;
13use crate::error::Result;
14use crate::pagination::Paginated;
15
16use super::agents::{AgentMcpServer, AgentModel, AgentTool, Skill};
17use super::betas;
18use super::resources::SessionResource;
19
20/// Session lifecycle status.
21///
22/// Sessions start in [`Idle`](Self::Idle), transition to
23/// [`Running`](Self::Running) while processing, and may briefly
24/// [`Rescheduling`](Self::Rescheduling) on transient retries.
25/// [`Terminated`](Self::Terminated) is terminal.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28#[non_exhaustive]
29pub enum SessionStatus {
30    /// Agent is waiting for input. Sessions start in this state.
31    Idle,
32    /// Agent is actively executing.
33    Running,
34    /// Transient error occurred; session is retrying automatically.
35    Rescheduling,
36    /// Session has ended due to an unrecoverable error.
37    Terminated,
38}
39
40/// Reference to an agent: either a string ID (latest version) or a
41/// pinned `{type, id, version}` object.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(untagged)]
44#[non_exhaustive]
45pub enum AgentRef {
46    /// Bare ID; resolves to the latest published version of the agent.
47    Latest(String),
48    /// Pinned to a specific version.
49    Pinned {
50        /// Always `"agent"`.
51        #[serde(rename = "type")]
52        ty: String,
53        /// Agent ID.
54        id: String,
55        /// Agent version number.
56        version: u32,
57    },
58}
59
60impl AgentRef {
61    /// Build an [`AgentRef::Latest`].
62    #[must_use]
63    pub fn latest(id: impl Into<String>) -> Self {
64        Self::Latest(id.into())
65    }
66
67    /// Build an [`AgentRef::Pinned`] for a specific version.
68    #[must_use]
69    pub fn pinned(id: impl Into<String>, version: u32) -> Self {
70        Self::Pinned {
71            ty: "agent".into(),
72            id: id.into(),
73            version,
74        }
75    }
76}
77
78impl From<&str> for AgentRef {
79    fn from(s: &str) -> Self {
80        Self::latest(s)
81    }
82}
83
84impl From<String> for AgentRef {
85    fn from(s: String) -> Self {
86        Self::latest(s)
87    }
88}
89
90/// Cumulative token usage on a session.
91#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
92#[non_exhaustive]
93pub struct SessionUsage {
94    /// Uncached input tokens billed across all model calls.
95    #[serde(default)]
96    pub input_tokens: u64,
97    /// Total output tokens across all model calls.
98    #[serde(default)]
99    pub output_tokens: u64,
100    /// Tokens written to the prompt cache, broken down by cache TTL.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub cache_creation: Option<CacheCreationUsage>,
103    /// Tokens served from the prompt cache (cheaper read path).
104    #[serde(default)]
105    pub cache_read_input_tokens: u64,
106}
107
108/// Prompt-cache write tokens, split by ephemeral TTL.
109#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
110#[non_exhaustive]
111pub struct CacheCreationUsage {
112    /// Tokens used to create 5-minute ephemeral cache entries.
113    #[serde(default)]
114    pub ephemeral_5m_input_tokens: u64,
115    /// Tokens used to create 1-hour ephemeral cache entries.
116    #[serde(default)]
117    pub ephemeral_1h_input_tokens: u64,
118}
119
120/// A Managed Agents session.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122#[non_exhaustive]
123pub struct Session {
124    /// Stable session identifier (`sesn_...`).
125    pub id: String,
126    /// Wire `type`; always `"session"`.
127    #[serde(rename = "type", default = "default_session_kind")]
128    pub kind: String,
129    /// Lifecycle status.
130    pub status: SessionStatus,
131    /// Resolved snapshot of the agent at session-creation time. Pinned
132    /// even if the underlying agent is later updated.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub agent: Option<SessionAgent>,
135    /// ID of the environment this session runs in.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub environment_id: Option<String>,
138    /// Vault references for MCP credential resolution.
139    #[serde(default, skip_serializing_if = "Vec::is_empty")]
140    pub vault_ids: Vec<String>,
141    /// Optional human-readable title.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub title: Option<String>,
144    /// Free-form key-value metadata attached at create time.
145    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
146    pub metadata: HashMap<String, String>,
147    /// Cumulative token usage. May be absent on freshly-created sessions.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub usage: Option<SessionUsage>,
150    /// Wall-clock and active-runtime stats.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub stats: Option<SessionStats>,
153    /// Mounted resources: file uploads, GitHub repositories, memory
154    /// stores. Each carries a server-assigned `id` (`sesrsc_...`) used
155    /// for [`Resources::update`](super::resources::Resources::update)
156    /// and [`Resources::delete`](super::resources::Resources::delete).
157    #[serde(default, skip_serializing_if = "Vec::is_empty")]
158    pub resources: Vec<SessionResource>,
159    /// Outcome evaluations recorded against this session, when an
160    /// outcome was defined. **Research-preview**: this field is
161    /// populated only when the session uses the outcomes feature
162    /// (`user.define_outcome` events, requires
163    /// `managed-agents-2026-04-01-research-preview` beta header).
164    /// Preserved as `Vec<Value>` until the outcomes types land.
165    #[serde(default, skip_serializing_if = "Vec::is_empty")]
166    pub outcome_evaluations: Vec<serde_json::Value>,
167    /// Timestamp when the session was created (RFC3339 / ISO 8601).
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub created_at: Option<String>,
170    /// Timestamp of the most recent update.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub updated_at: Option<String>,
173    /// Set when the session has been archived.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub archived_at: Option<String>,
176}
177
178fn default_session_kind() -> String {
179    "session".to_owned()
180}
181
182/// Snapshot of an agent's configuration at the moment a session was
183/// created. Mirrors [`Agent`](super::agents::Agent) but is pinned --
184/// later edits to the agent don't change a session's recorded
185/// snapshot.
186#[derive(Debug, Clone, Serialize, Deserialize)]
187#[non_exhaustive]
188pub struct SessionAgent {
189    /// Wire `type`; always `"agent"`.
190    #[serde(rename = "type", default = "default_session_agent_kind")]
191    pub kind: String,
192    /// Agent ID (`agnt_...`).
193    pub id: String,
194    /// Pinned agent version.
195    pub version: u32,
196    /// Agent name as it was at snapshot time.
197    pub name: String,
198    /// Agent description. May be `null` if no description was set.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub description: Option<String>,
201    /// Model configuration.
202    pub model: AgentModel,
203    /// System prompt. May be `null` if no system prompt was set.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub system: Option<String>,
206    /// Tools available to the agent at snapshot time.
207    #[serde(default, skip_serializing_if = "Vec::is_empty")]
208    pub tools: Vec<AgentTool>,
209    /// MCP servers configured.
210    #[serde(default, skip_serializing_if = "Vec::is_empty")]
211    pub mcp_servers: Vec<AgentMcpServer>,
212    /// Skills loaded into the container.
213    #[serde(default, skip_serializing_if = "Vec::is_empty")]
214    pub skills: Vec<Skill>,
215}
216
217fn default_session_agent_kind() -> String {
218    "agent".to_owned()
219}
220
221/// Wall-clock and active-runtime statistics for a session.
222#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
223#[non_exhaustive]
224pub struct SessionStats {
225    /// Total elapsed seconds since session creation.
226    pub duration_seconds: f64,
227    /// Seconds the agent was actively executing (excluding idle time).
228    pub active_seconds: f64,
229}
230
231/// Request body for `POST /v1/sessions`.
232///
233/// Build via [`CreateSessionRequest::builder`].
234#[derive(Debug, Clone, Serialize)]
235#[non_exhaustive]
236pub struct CreateSessionRequest {
237    /// The agent driving this session. May be a bare ID string (latest
238    /// version) or a pinned [`AgentRef::Pinned`].
239    pub agent: AgentRef,
240    /// Environment the session runs in.
241    pub environment_id: String,
242    /// Optional vault references for MCP credential resolution.
243    #[serde(skip_serializing_if = "Vec::is_empty")]
244    pub vault_ids: Vec<String>,
245    /// Optional resources mounted into the session container at
246    /// creation time. Build with the typed constructors in
247    /// [`crate::managed_agents::resources`].
248    #[serde(skip_serializing_if = "Vec::is_empty")]
249    pub resources: Vec<SessionResource>,
250    /// Optional human-readable title.
251    #[serde(skip_serializing_if = "Option::is_none")]
252    pub title: Option<String>,
253}
254
255impl CreateSessionRequest {
256    /// Begin configuring a request.
257    #[must_use]
258    pub fn builder() -> CreateSessionRequestBuilder {
259        CreateSessionRequestBuilder::default()
260    }
261}
262
263/// Builder for [`CreateSessionRequest`].
264#[derive(Debug, Default)]
265pub struct CreateSessionRequestBuilder {
266    agent: Option<AgentRef>,
267    environment_id: Option<String>,
268    vault_ids: Vec<String>,
269    resources: Vec<SessionResource>,
270    title: Option<String>,
271}
272
273impl CreateSessionRequestBuilder {
274    /// Set the agent. Required.
275    #[must_use]
276    pub fn agent(mut self, agent: impl Into<AgentRef>) -> Self {
277        self.agent = Some(agent.into());
278        self
279    }
280
281    /// Set the environment. Required.
282    #[must_use]
283    pub fn environment_id(mut self, id: impl Into<String>) -> Self {
284        self.environment_id = Some(id.into());
285        self
286    }
287
288    /// Append a vault ID for credential resolution.
289    #[must_use]
290    pub fn vault_id(mut self, id: impl Into<String>) -> Self {
291        self.vault_ids.push(id.into());
292        self
293    }
294
295    /// Set the full vault list.
296    #[must_use]
297    pub fn vault_ids(mut self, ids: Vec<String>) -> Self {
298        self.vault_ids = ids;
299        self
300    }
301
302    /// Append a typed resource (file / `github_repository` /
303    /// `memory_store`). Build via the constructors in
304    /// [`crate::managed_agents::resources`].
305    #[must_use]
306    pub fn resource(mut self, resource: SessionResource) -> Self {
307        self.resources.push(resource);
308        self
309    }
310
311    /// Set a human-readable title.
312    #[must_use]
313    pub fn title(mut self, title: impl Into<String>) -> Self {
314        self.title = Some(title.into());
315        self
316    }
317
318    /// Finalize.
319    ///
320    /// # Errors
321    ///
322    /// Returns [`Error::InvalidConfig`](crate::Error::InvalidConfig)
323    /// if `agent` or `environment_id` was not set.
324    pub fn build(self) -> Result<CreateSessionRequest> {
325        let agent = self
326            .agent
327            .ok_or_else(|| crate::Error::InvalidConfig("agent is required".into()))?;
328        let environment_id = self
329            .environment_id
330            .ok_or_else(|| crate::Error::InvalidConfig("environment_id is required".into()))?;
331        Ok(CreateSessionRequest {
332            agent,
333            environment_id,
334            vault_ids: self.vault_ids,
335            resources: self.resources,
336            title: self.title,
337        })
338    }
339}
340
341/// Optional knobs for [`Sessions::list`].
342#[derive(Debug, Clone, Default)]
343#[non_exhaustive]
344pub struct ListSessionsParams {
345    /// Pagination cursor: results after this session ID.
346    pub after: Option<String>,
347    /// Pagination cursor: results before this session ID.
348    pub before: Option<String>,
349    /// Page size limit.
350    pub limit: Option<u32>,
351    /// Whether to include archived sessions.
352    pub include_archived: Option<bool>,
353}
354
355impl ListSessionsParams {
356    fn to_query(&self) -> Vec<(&'static str, String)> {
357        let mut q = Vec::new();
358        if let Some(a) = &self.after {
359            q.push(("after", a.clone()));
360        }
361        if let Some(b) = &self.before {
362            q.push(("before", b.clone()));
363        }
364        if let Some(l) = self.limit {
365            q.push(("limit", l.to_string()));
366        }
367        if let Some(ia) = self.include_archived {
368            q.push(("include_archived", ia.to_string()));
369        }
370        q
371    }
372}
373
374/// Namespace handle for the Sessions API.
375///
376/// Obtained via [`Client::managed_agents`](Client::managed_agents).
377///
378/// To use the **outcomes** feature
379/// (`user.define_outcome` events, `span.outcome_evaluation_*` events,
380/// `Session.outcome_evaluations` field), call
381/// [`Self::with_research_preview`] to opt into the
382/// `managed-agents-2026-04-01-research-preview` beta header on every
383/// subsequent call. Without that opt-in the server returns a 4xx for
384/// outcomes-related requests.
385pub struct Sessions<'a> {
386    client: &'a Client,
387    research_preview: bool,
388}
389
390/// Request body for [`Sessions::update`]. All fields optional with
391/// merge-patch semantics: omit a field to preserve.
392///
393/// `metadata` is per-key: provide a `Some(value)` to upsert, or `None`
394/// to delete the key. Unspecified keys are preserved.
395#[derive(Debug, Clone, Default, Serialize)]
396#[non_exhaustive]
397pub struct UpdateSessionRequest {
398    /// New human-readable title (1-500 chars). `None` to leave
399    /// unchanged.
400    #[serde(skip_serializing_if = "Option::is_none")]
401    pub title: Option<String>,
402    /// Per-key metadata patch. See
403    /// [`MetadataPatch`](super::agents::MetadataPatch).
404    #[serde(skip_serializing_if = "Option::is_none")]
405    pub metadata: Option<super::agents::MetadataPatch>,
406    /// Replace the vault attachments. **Currently rejected by the
407    /// server**; reserved for future use.
408    #[serde(skip_serializing_if = "Vec::is_empty")]
409    pub vault_ids: Vec<String>,
410}
411
412impl UpdateSessionRequest {
413    /// Empty patch.
414    #[must_use]
415    pub fn new() -> Self {
416        Self::default()
417    }
418
419    /// Set the new title.
420    #[must_use]
421    pub fn title(mut self, title: impl Into<String>) -> Self {
422        self.title = Some(title.into());
423        self
424    }
425
426    /// Apply a metadata patch.
427    #[must_use]
428    pub fn metadata(mut self, patch: super::agents::MetadataPatch) -> Self {
429        self.metadata = Some(patch);
430        self
431    }
432}
433
434impl<'a> Sessions<'a> {
435    pub(crate) fn new(client: &'a Client) -> Self {
436        Self {
437            client,
438            research_preview: false,
439        }
440    }
441
442    /// Opt into the
443    /// `managed-agents-2026-04-01-research-preview` beta header on
444    /// every subsequent call made through this handle (and any
445    /// downstream [`Events`](super::events::Events) /
446    /// [`Threads`](super::threads::Threads) sub-handles).
447    ///
448    /// Required when:
449    ///
450    /// - Sending `user.define_outcome` events
451    /// - Reading `Session.outcome_evaluations` from a response
452    /// - Streaming `span.outcome_evaluation_*` events
453    ///
454    /// Multi-agent threads do **not** require this header (per the
455    /// public guide's curl examples), but enabling it is harmless --
456    /// the server ignores beta headers it doesn't recognize for an
457    /// endpoint.
458    ///
459    /// ```no_run
460    /// # use claude_api::Client;
461    /// # async fn run(client: &Client) -> Result<(), claude_api::Error> {
462    /// let sessions = client.managed_agents().sessions().with_research_preview();
463    /// // Now `sessions.events(id).send(...)` and `sessions.retrieve(id)`
464    /// // include the research-preview header automatically.
465    /// # Ok(()) }
466    /// ```
467    #[must_use]
468    pub fn with_research_preview(mut self) -> Self {
469        self.research_preview = true;
470        self
471    }
472
473    /// Create a session. Returns the freshly-provisioned [`Session`].
474    pub async fn create(&self, request: CreateSessionRequest) -> Result<Session> {
475        let request_ref = &request;
476        self.client
477            .execute_with_retry(
478                || {
479                    self.client
480                        .request_builder(reqwest::Method::POST, "/v1/sessions")
481                        .json(request_ref)
482                },
483                betas(self.research_preview),
484            )
485            .await
486    }
487
488    /// Fetch a session by ID.
489    pub async fn retrieve(&self, session_id: &str) -> Result<Session> {
490        let path = format!("/v1/sessions/{session_id}");
491        self.client
492            .execute_with_retry(
493                || self.client.request_builder(reqwest::Method::GET, &path),
494                betas(self.research_preview),
495            )
496            .await
497    }
498
499    /// List sessions, paginated.
500    pub async fn list(&self, params: ListSessionsParams) -> Result<Paginated<Session>> {
501        let query = params.to_query();
502        self.client
503            .execute_with_retry(
504                || {
505                    let mut req = self
506                        .client
507                        .request_builder(reqwest::Method::GET, "/v1/sessions");
508                    for (k, v) in &query {
509                        req = req.query(&[(k, v)]);
510                    }
511                    req
512                },
513                betas(self.research_preview),
514            )
515            .await
516    }
517
518    /// Update a session. All fields on [`UpdateSessionRequest`] are
519    /// optional with merge-patch semantics: omit a field to preserve
520    /// its current value.
521    pub async fn update(&self, session_id: &str, request: UpdateSessionRequest) -> Result<Session> {
522        let path = format!("/v1/sessions/{session_id}");
523        let request_ref = &request;
524        self.client
525            .execute_with_retry(
526                || {
527                    self.client
528                        .request_builder(reqwest::Method::POST, &path)
529                        .json(request_ref)
530                },
531                betas(self.research_preview),
532            )
533            .await
534    }
535
536    /// Archive a session. Archived sessions cannot accept new events but
537    /// preserve their event history.
538    pub async fn archive(&self, session_id: &str) -> Result<Session> {
539        let path = format!("/v1/sessions/{session_id}/archive");
540        self.client
541            .execute_with_retry(
542                || self.client.request_builder(reqwest::Method::POST, &path),
543                betas(self.research_preview),
544            )
545            .await
546    }
547
548    /// Sub-namespace for the session-events API
549    /// (`/v1/sessions/{id}/events` and the SSE stream). Inherits the
550    /// research-preview opt-in from this `Sessions` handle.
551    #[must_use]
552    pub fn events(&self, session_id: impl Into<String>) -> super::events::Events<'_> {
553        super::events::Events {
554            client: self.client,
555            session_id: session_id.into(),
556            research_preview: self.research_preview,
557        }
558    }
559
560    /// Sub-namespace for resource operations on a session
561    /// (`/v1/sessions/{id}/resources`). Resources don't use
562    /// research-preview features but the flag is propagated for
563    /// consistency.
564    #[must_use]
565    pub fn resources(&self, session_id: impl Into<String>) -> super::resources::Resources<'_> {
566        super::resources::Resources {
567            client: self.client,
568            session_id: session_id.into(),
569        }
570    }
571
572    /// Sub-namespace for thread operations on a multi-agent session
573    /// (`/v1/sessions/{id}/threads`). Sub-agent threads are spawned at
574    /// runtime when the coordinator delegates to a `callable_agent`.
575    #[must_use]
576    pub fn threads(&self, session_id: impl Into<String>) -> super::threads::Threads<'_> {
577        super::threads::Threads {
578            client: self.client,
579            session_id: session_id.into(),
580            research_preview: self.research_preview,
581        }
582    }
583
584    /// Delete a session permanently. The session must not be `running`;
585    /// send a `user.interrupt` event first if necessary. Files, memory
586    /// stores, environments, and agents are independent and not
587    /// affected.
588    pub async fn delete(&self, session_id: &str) -> Result<()> {
589        let path = format!("/v1/sessions/{session_id}");
590        // The delete endpoint returns 204 No Content; route through a
591        // dummy `serde_json::Value` to satisfy execute()'s
592        // DeserializeOwned bound and discard the result.
593        let _: serde_json::Value = self
594            .client
595            .execute_with_retry(
596                || self.client.request_builder(reqwest::Method::DELETE, &path),
597                betas(self.research_preview),
598            )
599            .await?;
600        Ok(())
601    }
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607    use pretty_assertions::assert_eq;
608    use serde_json::json;
609    use wiremock::matchers::{body_partial_json, header, method, path};
610    use wiremock::{Mock, MockServer, ResponseTemplate};
611
612    fn client_for(mock: &MockServer) -> Client {
613        Client::builder()
614            .api_key("sk-ant-test")
615            .base_url(mock.uri())
616            .build()
617            .unwrap()
618    }
619
620    fn fake_session(id: &str) -> serde_json::Value {
621        json!({
622            "id": id,
623            "status": "idle",
624            "title": "Test session",
625            "usage": {
626                "input_tokens": 0,
627                "output_tokens": 0,
628                "cache_creation_input_tokens": 0,
629                "cache_read_input_tokens": 0
630            },
631            "created_at": "2026-04-30T12:00:00Z"
632        })
633    }
634
635    #[test]
636    fn agent_ref_serializes_string_form_untagged() {
637        let r = AgentRef::latest("agent_01ABC");
638        let v = serde_json::to_value(&r).unwrap();
639        assert_eq!(v, json!("agent_01ABC"));
640    }
641
642    #[test]
643    fn agent_ref_serializes_pinned_form_with_type_tag() {
644        let r = AgentRef::pinned("agent_01ABC", 3);
645        let v = serde_json::to_value(&r).unwrap();
646        assert_eq!(
647            v,
648            json!({"type": "agent", "id": "agent_01ABC", "version": 3})
649        );
650    }
651
652    #[test]
653    fn agent_ref_round_trips_both_forms() {
654        for r in [AgentRef::latest("a"), AgentRef::pinned("a", 1)] {
655            let v = serde_json::to_value(&r).unwrap();
656            let parsed: AgentRef = serde_json::from_value(v).unwrap();
657            assert_eq!(parsed, r);
658        }
659    }
660
661    #[test]
662    fn create_session_request_drops_empty_optional_fields() {
663        let req = CreateSessionRequest::builder()
664            .agent("agent_01")
665            .environment_id("env_01")
666            .build()
667            .unwrap();
668        let v = serde_json::to_value(&req).unwrap();
669        assert!(v.get("vault_ids").is_none(), "{v}");
670        assert!(v.get("resources").is_none(), "{v}");
671        assert!(v.get("title").is_none(), "{v}");
672    }
673
674    #[tokio::test]
675    async fn create_posts_to_v1_sessions_with_beta_header() {
676        let mock = MockServer::start().await;
677        Mock::given(method("POST"))
678            .and(path("/v1/sessions"))
679            .and(header("anthropic-beta", "managed-agents-2026-04-01"))
680            .and(body_partial_json(json!({
681                "agent": "agent_01",
682                "environment_id": "env_01"
683            })))
684            .respond_with(ResponseTemplate::new(200).set_body_json(fake_session("sesn_01")))
685            .mount(&mock)
686            .await;
687
688        let client = client_for(&mock);
689        let req = CreateSessionRequest::builder()
690            .agent("agent_01")
691            .environment_id("env_01")
692            .build()
693            .unwrap();
694        let s = client
695            .managed_agents()
696            .sessions()
697            .create(req)
698            .await
699            .unwrap();
700        assert_eq!(s.id, "sesn_01");
701        assert_eq!(s.status, SessionStatus::Idle);
702        assert_eq!(s.title.as_deref(), Some("Test session"));
703    }
704
705    #[tokio::test]
706    async fn create_with_pinned_agent_serializes_object_form() {
707        let mock = MockServer::start().await;
708        Mock::given(method("POST"))
709            .and(path("/v1/sessions"))
710            .and(body_partial_json(json!({
711                "agent": {"type": "agent", "id": "agent_01", "version": 2}
712            })))
713            .respond_with(ResponseTemplate::new(200).set_body_json(fake_session("sesn_01")))
714            .mount(&mock)
715            .await;
716
717        let client = client_for(&mock);
718        let req = CreateSessionRequest::builder()
719            .agent(AgentRef::pinned("agent_01", 2))
720            .environment_id("env_01")
721            .build()
722            .unwrap();
723        let _ = client
724            .managed_agents()
725            .sessions()
726            .create(req)
727            .await
728            .unwrap();
729    }
730
731    #[tokio::test]
732    async fn create_with_vault_ids_includes_them_in_body() {
733        let mock = MockServer::start().await;
734        Mock::given(method("POST"))
735            .and(path("/v1/sessions"))
736            .and(body_partial_json(
737                json!({"vault_ids": ["vault_01", "vault_02"]}),
738            ))
739            .respond_with(ResponseTemplate::new(200).set_body_json(fake_session("sesn_01")))
740            .mount(&mock)
741            .await;
742
743        let client = client_for(&mock);
744        let req = CreateSessionRequest::builder()
745            .agent("agent_01")
746            .environment_id("env_01")
747            .vault_id("vault_01")
748            .vault_id("vault_02")
749            .build()
750            .unwrap();
751        let _ = client
752            .managed_agents()
753            .sessions()
754            .create(req)
755            .await
756            .unwrap();
757    }
758
759    #[tokio::test]
760    async fn retrieve_returns_typed_session() {
761        let mock = MockServer::start().await;
762        Mock::given(method("GET"))
763            .and(path("/v1/sessions/sesn_42"))
764            .and(header("anthropic-beta", "managed-agents-2026-04-01"))
765            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
766                "id": "sesn_42",
767                "status": "running"
768            })))
769            .mount(&mock)
770            .await;
771
772        let client = client_for(&mock);
773        let s = client
774            .managed_agents()
775            .sessions()
776            .retrieve("sesn_42")
777            .await
778            .unwrap();
779        assert_eq!(s.id, "sesn_42");
780        assert_eq!(s.status, SessionStatus::Running);
781    }
782
783    #[tokio::test]
784    async fn list_passes_pagination_query_params() {
785        let mock = MockServer::start().await;
786        Mock::given(method("GET"))
787            .and(path("/v1/sessions"))
788            .and(wiremock::matchers::query_param("limit", "5"))
789            .and(wiremock::matchers::query_param("include_archived", "true"))
790            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
791                "data": [
792                    {"id": "sesn_a", "status": "idle"},
793                    {"id": "sesn_b", "status": "terminated"}
794                ],
795                "has_more": false
796            })))
797            .mount(&mock)
798            .await;
799
800        let client = client_for(&mock);
801        let page = client
802            .managed_agents()
803            .sessions()
804            .list(ListSessionsParams {
805                limit: Some(5),
806                include_archived: Some(true),
807                ..Default::default()
808            })
809            .await
810            .unwrap();
811        assert_eq!(page.data.len(), 2);
812    }
813
814    #[tokio::test]
815    async fn archive_posts_to_archive_subpath() {
816        let mock = MockServer::start().await;
817        Mock::given(method("POST"))
818            .and(path("/v1/sessions/sesn_x/archive"))
819            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
820                "id": "sesn_x",
821                "status": "idle",
822                "archived_at": "2026-04-30T12:00:00Z"
823            })))
824            .mount(&mock)
825            .await;
826
827        let client = client_for(&mock);
828        let s = client
829            .managed_agents()
830            .sessions()
831            .archive("sesn_x")
832            .await
833            .unwrap();
834        assert_eq!(s.archived_at.as_deref(), Some("2026-04-30T12:00:00Z"));
835    }
836
837    #[tokio::test]
838    async fn delete_returns_unit_on_success() {
839        let mock = MockServer::start().await;
840        Mock::given(method("DELETE"))
841            .and(path("/v1/sessions/sesn_x"))
842            .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
843            .mount(&mock)
844            .await;
845
846        let client = client_for(&mock);
847        client
848            .managed_agents()
849            .sessions()
850            .delete("sesn_x")
851            .await
852            .unwrap();
853    }
854
855    #[tokio::test]
856    async fn update_posts_to_session_path_with_merge_patch_body() {
857        let mock = MockServer::start().await;
858        Mock::given(method("POST"))
859            .and(path("/v1/sessions/sesn_u"))
860            .and(body_partial_json(json!({
861                "title": "renamed",
862                "metadata": {"plan": "pro", "old": null}
863            })))
864            .respond_with(ResponseTemplate::new(200).set_body_json(fake_session("sesn_u")))
865            .mount(&mock)
866            .await;
867
868        let client = client_for(&mock);
869        let s = client
870            .managed_agents()
871            .sessions()
872            .update(
873                "sesn_u",
874                UpdateSessionRequest::new().title("renamed").metadata(
875                    super::super::agents::MetadataPatch::new()
876                        .set("plan", "pro")
877                        .delete("old"),
878                ),
879            )
880            .await
881            .unwrap();
882        assert_eq!(s.id, "sesn_u");
883    }
884
885    #[test]
886    fn session_decodes_full_response_with_agent_snapshot_environment_and_stats() {
887        // Fixture lifted from the spec example for BetaManagedAgentsSession.
888        let raw = json!({
889            "id": "sesn_full",
890            "type": "session",
891            "status": "idle",
892            "agent": {
893                "type": "agent",
894                "id": "agent_X",
895                "version": 3,
896                "name": "Lead",
897                "description": "An agent",
898                "model": "claude-sonnet-4-6",
899                "system": "you are an agent",
900                "tools": [],
901                "mcp_servers": [],
902                "skills": []
903            },
904            "environment_id": "env_Y",
905            "vault_ids": ["vlt_a", "vlt_b"],
906            "title": "demo",
907            "metadata": {"team": "research"},
908            "stats": {"duration_seconds": 123.5, "active_seconds": 45.0},
909            "resources": [],
910            "created_at": "2026-04-30T12:00:00Z",
911            "updated_at": "2026-04-30T12:01:00Z"
912        });
913        let s: Session = serde_json::from_value(raw).unwrap();
914        assert_eq!(s.kind, "session");
915        let agent = s.agent.unwrap();
916        assert_eq!(agent.id, "agent_X");
917        assert_eq!(agent.version, 3);
918        assert_eq!(s.environment_id.as_deref(), Some("env_Y"));
919        assert_eq!(s.vault_ids, vec!["vlt_a", "vlt_b"]);
920        assert_eq!(s.metadata.get("team").map(String::as_str), Some("research"));
921        let stats = s.stats.unwrap();
922        assert!((stats.duration_seconds - 123.5).abs() < 1e-6);
923        assert!((stats.active_seconds - 45.0).abs() < 1e-6);
924    }
925
926    #[tokio::test]
927    async fn retrieve_without_research_preview_sends_only_base_beta() {
928        let mock = MockServer::start().await;
929        Mock::given(method("GET"))
930            .and(path("/v1/sessions/sesn_x"))
931            .and(header("anthropic-beta", "managed-agents-2026-04-01"))
932            .respond_with(ResponseTemplate::new(200).set_body_json(fake_session("sesn_x")))
933            .mount(&mock)
934            .await;
935        let client = client_for(&mock);
936        let _ = client
937            .managed_agents()
938            .sessions()
939            .retrieve("sesn_x")
940            .await
941            .unwrap();
942    }
943
944    #[tokio::test]
945    async fn retrieve_with_research_preview_sends_both_beta_headers() {
946        let mock = MockServer::start().await;
947        Mock::given(method("GET"))
948            .and(path("/v1/sessions/sesn_x"))
949            .respond_with(ResponseTemplate::new(200).set_body_json(fake_session("sesn_x")))
950            .mount(&mock)
951            .await;
952        let client = client_for(&mock);
953        let _ = client
954            .managed_agents()
955            .sessions()
956            .with_research_preview()
957            .retrieve("sesn_x")
958            .await
959            .unwrap();
960        let received = &mock.received_requests().await.unwrap()[0];
961        let beta = received
962            .headers
963            .get("anthropic-beta")
964            .unwrap()
965            .to_str()
966            .unwrap();
967        assert!(
968            beta.contains("managed-agents-2026-04-01")
969                && beta.contains("managed-agents-2026-04-01-research-preview"),
970            "expected both beta values, got {beta}"
971        );
972    }
973
974    #[tokio::test]
975    async fn events_sub_handle_inherits_research_preview_flag() {
976        let mock = MockServer::start().await;
977        Mock::given(method("POST"))
978            .and(path("/v1/sessions/sesn_x/events"))
979            .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
980            .mount(&mock)
981            .await;
982        let client = client_for(&mock);
983        client
984            .managed_agents()
985            .sessions()
986            .with_research_preview()
987            .events("sesn_x")
988            .send(&[super::super::events::OutgoingUserEvent::message("ping")])
989            .await
990            .unwrap();
991        let received = &mock.received_requests().await.unwrap()[0];
992        let beta = received
993            .headers
994            .get("anthropic-beta")
995            .unwrap()
996            .to_str()
997            .unwrap();
998        assert!(
999            beta.contains("managed-agents-2026-04-01-research-preview"),
1000            "events sub-handle did not inherit research_preview flag (beta={beta})"
1001        );
1002    }
1003
1004    #[tokio::test]
1005    async fn threads_sub_handle_inherits_research_preview_flag() {
1006        let mock = MockServer::start().await;
1007        Mock::given(method("GET"))
1008            .and(path("/v1/sessions/sesn_x/threads"))
1009            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1010                "data": [],
1011                "has_more": false
1012            })))
1013            .mount(&mock)
1014            .await;
1015        let client = client_for(&mock);
1016        let _ = client
1017            .managed_agents()
1018            .sessions()
1019            .with_research_preview()
1020            .threads("sesn_x")
1021            .list()
1022            .await
1023            .unwrap();
1024        let received = &mock.received_requests().await.unwrap()[0];
1025        let beta = received
1026            .headers
1027            .get("anthropic-beta")
1028            .unwrap()
1029            .to_str()
1030            .unwrap();
1031        assert!(
1032            beta.contains("managed-agents-2026-04-01-research-preview"),
1033            "threads sub-handle did not inherit research_preview flag (beta={beta})"
1034        );
1035    }
1036}