Skip to main content

claude_api/managed_agents/
resources.rs

1//! Session resources: file / `github_repository` / `memory_store` mounts.
2//!
3//! Resources are attached to a session at creation time (via
4//! [`CreateSessionRequest::resources`](super::sessions::CreateSessionRequest::resources))
5//! or added afterwards via the [`Resources`] sub-namespace. Each
6//! resource has a server-assigned ID (`sesrsc_...`) used for update
7//! and delete operations.
8//!
9//! Three known resource kinds are typed below; an unknown kind on the
10//! wire deserializes into [`SessionResource::Other`] preserving the
11//! raw JSON.
12
13use serde::{Deserialize, Serialize};
14
15use crate::client::Client;
16use crate::error::Result;
17use crate::pagination::Paginated;
18
19use super::MANAGED_AGENTS_BETA;
20
21// =====================================================================
22// Resource types
23// =====================================================================
24
25/// One resource mounted into a session container.
26///
27/// Forward-compatible: unknown wire `type` tags fall through to
28/// [`Self::Other`] preserving the raw JSON.
29#[derive(Debug, Clone, PartialEq)]
30pub enum SessionResource {
31    /// File mounted from the [Files API](crate::files).
32    File(FileResource),
33    /// GitHub repository cloned into the container.
34    GitHubRepository(GitHubRepositoryResource),
35    /// Memory store mounted under `/mnt/memory/`.
36    MemoryStore(MemoryStoreResource),
37    /// Unknown resource kind; raw JSON preserved.
38    Other(serde_json::Value),
39}
40
41/// `type: "file"` resource.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[non_exhaustive]
44pub struct FileResource {
45    /// Server-assigned resource ID, present on responses.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub id: Option<String>,
48    /// Files API ID of the uploaded file.
49    pub file_id: String,
50    /// Optional mount path inside the container. The server picks a
51    /// path under the working directory when omitted; pass an explicit
52    /// path for predictable references.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub mount_path: Option<String>,
55    /// Creation timestamp (RFC3339).
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub created_at: Option<String>,
58    /// Last-modified timestamp (RFC3339).
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub updated_at: Option<String>,
61}
62
63impl FileResource {
64    /// Build a file mount with an optional explicit mount path.
65    #[must_use]
66    pub fn new(file_id: impl Into<String>) -> Self {
67        Self {
68            id: None,
69            file_id: file_id.into(),
70            mount_path: None,
71            created_at: None,
72            updated_at: None,
73        }
74    }
75
76    /// Set an explicit mount path.
77    #[must_use]
78    pub fn mount_path(mut self, path: impl Into<String>) -> Self {
79        self.mount_path = Some(path.into());
80        self
81    }
82}
83
84/// What ref to check out for a [`GitHubRepositoryResource`]. Tagged
85/// union of branch (by name) or commit (by SHA).
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(tag = "type", rename_all = "snake_case")]
88#[non_exhaustive]
89pub enum RepositoryCheckout {
90    /// Check out a branch by name.
91    Branch {
92        /// Branch name (e.g. `"main"`).
93        name: String,
94    },
95    /// Check out a specific commit.
96    Commit {
97        /// Full commit SHA.
98        sha: String,
99    },
100}
101
102/// `type: "github_repository"` resource.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[non_exhaustive]
105pub struct GitHubRepositoryResource {
106    /// Server-assigned resource ID, present on responses.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub id: Option<String>,
109    /// HTTPS URL of the repository.
110    pub url: String,
111    /// Mount path inside the container.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub mount_path: Option<String>,
114    /// Branch / commit to check out.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub checkout: Option<RepositoryCheckout>,
117    /// GitHub access token. **Write-only**: the server stores this
118    /// internally and never echoes it on responses, so this field is
119    /// always `None` on retrieved resources.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub authorization_token: Option<String>,
122    /// Creation timestamp (RFC3339).
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub created_at: Option<String>,
125    /// Last-modified timestamp (RFC3339).
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub updated_at: Option<String>,
128}
129
130impl GitHubRepositoryResource {
131    /// Build a repository mount.
132    #[must_use]
133    pub fn new(url: impl Into<String>, authorization_token: impl Into<String>) -> Self {
134        Self {
135            id: None,
136            url: url.into(),
137            mount_path: None,
138            checkout: None,
139            authorization_token: Some(authorization_token.into()),
140            created_at: None,
141            updated_at: None,
142        }
143    }
144
145    /// Set the branch / commit checkout.
146    #[must_use]
147    pub fn checkout(mut self, checkout: RepositoryCheckout) -> Self {
148        self.checkout = Some(checkout);
149        self
150    }
151
152    /// Set an explicit mount path.
153    #[must_use]
154    pub fn mount_path(mut self, path: impl Into<String>) -> Self {
155        self.mount_path = Some(path.into());
156        self
157    }
158}
159
160/// Access mode for a [`MemoryStoreResource`].
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163#[non_exhaustive]
164pub enum MemoryStoreAccess {
165    /// Reference material the agent can read but not write.
166    ReadOnly,
167    /// Default. Writes produce new memory versions attributed to the
168    /// session.
169    ReadWrite,
170}
171
172/// `type: "memory_store"` resource.
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174#[non_exhaustive]
175pub struct MemoryStoreResource {
176    /// Server-assigned resource ID, present on responses. Note: the
177    /// spec doesn't formally enumerate this field for the memory-store
178    /// variant, but responses include it; preserved for round-trip
179    /// fidelity and for the unified [`SessionResource::id`] accessor.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub id: Option<String>,
182    /// ID of the memory store to mount.
183    pub memory_store_id: String,
184    /// Snapshotted memory-store name (set by the server on responses).
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub name: Option<String>,
187    /// Snapshotted description (set by the server on responses).
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub description: Option<String>,
190    /// Mount path inside the container.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub mount_path: Option<String>,
193    /// Access mode. Defaults to `read_write` server-side.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub access: Option<MemoryStoreAccess>,
196    /// Optional session-specific instructions for how the agent should
197    /// use this store. Capped at 4,096 characters.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub instructions: Option<String>,
200}
201
202impl MemoryStoreResource {
203    /// Build a memory-store mount with default access.
204    #[must_use]
205    pub fn new(memory_store_id: impl Into<String>) -> Self {
206        Self {
207            id: None,
208            memory_store_id: memory_store_id.into(),
209            name: None,
210            description: None,
211            mount_path: None,
212            access: None,
213            instructions: None,
214        }
215    }
216
217    /// Set an explicit mount path.
218    #[must_use]
219    pub fn mount_path(mut self, path: impl Into<String>) -> Self {
220        self.mount_path = Some(path.into());
221        self
222    }
223
224    /// Set explicit access.
225    #[must_use]
226    pub fn access(mut self, access: MemoryStoreAccess) -> Self {
227        self.access = Some(access);
228        self
229    }
230
231    /// Set session-specific instructions.
232    #[must_use]
233    pub fn instructions(mut self, instructions: impl Into<String>) -> Self {
234        self.instructions = Some(instructions.into());
235        self
236    }
237}
238
239const KNOWN_RESOURCE_TAGS: &[&str] = &["file", "github_repository", "memory_store"];
240
241impl Serialize for SessionResource {
242    fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
243        use serde::ser::SerializeMap;
244        match self {
245            Self::File(r) => {
246                let mut map = s.serialize_map(None)?;
247                map.serialize_entry("type", "file")?;
248                if let Some(id) = &r.id {
249                    map.serialize_entry("id", id)?;
250                }
251                map.serialize_entry("file_id", &r.file_id)?;
252                if let Some(mp) = &r.mount_path {
253                    map.serialize_entry("mount_path", mp)?;
254                }
255                map.end()
256            }
257            Self::GitHubRepository(r) => {
258                let mut map = s.serialize_map(None)?;
259                map.serialize_entry("type", "github_repository")?;
260                if let Some(id) = &r.id {
261                    map.serialize_entry("id", id)?;
262                }
263                map.serialize_entry("url", &r.url)?;
264                if let Some(mp) = &r.mount_path {
265                    map.serialize_entry("mount_path", mp)?;
266                }
267                if let Some(t) = &r.authorization_token {
268                    map.serialize_entry("authorization_token", t)?;
269                }
270                map.end()
271            }
272            Self::MemoryStore(r) => {
273                let mut map = s.serialize_map(None)?;
274                map.serialize_entry("type", "memory_store")?;
275                if let Some(id) = &r.id {
276                    map.serialize_entry("id", id)?;
277                }
278                map.serialize_entry("memory_store_id", &r.memory_store_id)?;
279                if let Some(a) = r.access {
280                    map.serialize_entry("access", &a)?;
281                }
282                if let Some(i) = &r.instructions {
283                    map.serialize_entry("instructions", i)?;
284                }
285                map.end()
286            }
287            Self::Other(v) => v.serialize(s),
288        }
289    }
290}
291
292impl<'de> Deserialize<'de> for SessionResource {
293    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
294        let raw = serde_json::Value::deserialize(d)?;
295        let tag = raw.get("type").and_then(serde_json::Value::as_str);
296        match tag {
297            Some("file") if KNOWN_RESOURCE_TAGS.contains(&"file") => {
298                let r = serde_json::from_value::<FileResource>(raw)
299                    .map_err(serde::de::Error::custom)?;
300                Ok(Self::File(r))
301            }
302            Some("github_repository") => {
303                let r = serde_json::from_value::<GitHubRepositoryResource>(raw)
304                    .map_err(serde::de::Error::custom)?;
305                Ok(Self::GitHubRepository(r))
306            }
307            Some("memory_store") => {
308                let r = serde_json::from_value::<MemoryStoreResource>(raw)
309                    .map_err(serde::de::Error::custom)?;
310                Ok(Self::MemoryStore(r))
311            }
312            _ => Ok(Self::Other(raw)),
313        }
314    }
315}
316
317impl SessionResource {
318    /// Server-assigned resource ID, if any.
319    #[must_use]
320    pub fn id(&self) -> Option<&str> {
321        match self {
322            Self::File(r) => r.id.as_deref(),
323            Self::GitHubRepository(r) => r.id.as_deref(),
324            Self::MemoryStore(r) => r.id.as_deref(),
325            Self::Other(v) => v.get("id").and_then(serde_json::Value::as_str),
326        }
327    }
328}
329
330// =====================================================================
331// Update payloads
332// =====================================================================
333
334/// Patch for an existing session resource. Currently only the
335/// `github_repository`'s `authorization_token` is mutable.
336#[derive(Debug, Clone, Default, Serialize)]
337#[non_exhaustive]
338pub struct UpdateResourceRequest {
339    /// New GitHub access token. Leave `None` to make no change.
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub authorization_token: Option<String>,
342}
343
344impl UpdateResourceRequest {
345    /// Build a request that rotates the GitHub authorization token.
346    #[must_use]
347    pub fn rotate_authorization_token(token: impl Into<String>) -> Self {
348        Self {
349            authorization_token: Some(token.into()),
350        }
351    }
352}
353
354// =====================================================================
355// Namespace handle
356// =====================================================================
357
358/// Namespace handle for resource operations on a single session.
359///
360/// Obtained via
361/// [`Sessions::resources`](super::sessions::Sessions::resources).
362pub struct Resources<'a> {
363    pub(crate) client: &'a Client,
364    pub(crate) session_id: String,
365}
366
367impl Resources<'_> {
368    /// `GET /v1/sessions/{session_id}/resources`.
369    pub async fn list(&self) -> Result<Paginated<SessionResource>> {
370        let path = format!("/v1/sessions/{}/resources", self.session_id);
371        self.client
372            .execute_with_retry(
373                || self.client.request_builder(reqwest::Method::GET, &path),
374                &[MANAGED_AGENTS_BETA],
375            )
376            .await
377    }
378
379    /// `GET /v1/sessions/{session_id}/resources/{resource_id}`. Fetch a
380    /// single mounted resource by ID.
381    pub async fn retrieve(&self, resource_id: &str) -> Result<SessionResource> {
382        let path = format!("/v1/sessions/{}/resources/{resource_id}", self.session_id);
383        self.client
384            .execute_with_retry(
385                || self.client.request_builder(reqwest::Method::GET, &path),
386                &[MANAGED_AGENTS_BETA],
387            )
388            .await
389    }
390
391    /// `POST /v1/sessions/{session_id}/resources`. Add a resource to a
392    /// running session.
393    pub async fn add(&self, resource: &SessionResource) -> Result<SessionResource> {
394        let path = format!("/v1/sessions/{}/resources", self.session_id);
395        let body = resource;
396        self.client
397            .execute_with_retry(
398                || {
399                    self.client
400                        .request_builder(reqwest::Method::POST, &path)
401                        .json(body)
402                },
403                &[MANAGED_AGENTS_BETA],
404            )
405            .await
406    }
407
408    /// `POST /v1/sessions/{session_id}/resources/{resource_id}`. Used
409    /// to rotate a `github_repository` resource's `authorization_token`.
410    pub async fn update(
411        &self,
412        resource_id: &str,
413        request: UpdateResourceRequest,
414    ) -> Result<SessionResource> {
415        let path = format!("/v1/sessions/{}/resources/{resource_id}", self.session_id);
416        let body = &request;
417        self.client
418            .execute_with_retry(
419                || {
420                    self.client
421                        .request_builder(reqwest::Method::POST, &path)
422                        .json(body)
423                },
424                &[MANAGED_AGENTS_BETA],
425            )
426            .await
427    }
428
429    /// `DELETE /v1/sessions/{session_id}/resources/{resource_id}`.
430    pub async fn delete(&self, resource_id: &str) -> Result<()> {
431        let path = format!("/v1/sessions/{}/resources/{resource_id}", self.session_id);
432        let _: serde_json::Value = self
433            .client
434            .execute_with_retry(
435                || self.client.request_builder(reqwest::Method::DELETE, &path),
436                &[MANAGED_AGENTS_BETA],
437            )
438            .await?;
439        Ok(())
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use pretty_assertions::assert_eq;
447    use serde_json::json;
448    use wiremock::matchers::{body_partial_json, method, path};
449    use wiremock::{Mock, MockServer, ResponseTemplate};
450
451    fn client_for(mock: &MockServer) -> Client {
452        Client::builder()
453            .api_key("sk-ant-test")
454            .base_url(mock.uri())
455            .build()
456            .unwrap()
457    }
458
459    #[test]
460    fn file_resource_round_trips_with_mount_path() {
461        let r =
462            SessionResource::File(FileResource::new("file_01").mount_path("/workspace/data.csv"));
463        let v = serde_json::to_value(&r).unwrap();
464        assert_eq!(
465            v,
466            json!({
467                "type": "file",
468                "file_id": "file_01",
469                "mount_path": "/workspace/data.csv"
470            })
471        );
472        let parsed: SessionResource = serde_json::from_value(v).unwrap();
473        assert_eq!(parsed, r);
474    }
475
476    #[test]
477    fn github_resource_serializes_authorization_token_on_create() {
478        let r = SessionResource::GitHubRepository(
479            GitHubRepositoryResource::new("https://github.com/org/repo", "ghp_xxx")
480                .mount_path("/workspace/repo"),
481        );
482        let v = serde_json::to_value(&r).unwrap();
483        assert_eq!(v["authorization_token"], "ghp_xxx");
484        assert_eq!(v["mount_path"], "/workspace/repo");
485    }
486
487    #[test]
488    fn memory_store_resource_round_trips_with_access_and_instructions() {
489        let r = SessionResource::MemoryStore(
490            MemoryStoreResource::new("memstore_01")
491                .access(MemoryStoreAccess::ReadOnly)
492                .instructions("Reference only."),
493        );
494        let v = serde_json::to_value(&r).unwrap();
495        assert_eq!(
496            v,
497            json!({
498                "type": "memory_store",
499                "memory_store_id": "memstore_01",
500                "access": "read_only",
501                "instructions": "Reference only."
502            })
503        );
504        let parsed: SessionResource = serde_json::from_value(v).unwrap();
505        assert_eq!(parsed, r);
506    }
507
508    #[test]
509    fn repository_checkout_round_trips_branch_and_commit() {
510        let branch = RepositoryCheckout::Branch {
511            name: "main".into(),
512        };
513        let v = serde_json::to_value(&branch).unwrap();
514        assert_eq!(v, json!({"type": "branch", "name": "main"}));
515        let parsed: RepositoryCheckout = serde_json::from_value(v).unwrap();
516        assert_eq!(parsed, branch);
517
518        let commit = RepositoryCheckout::Commit {
519            sha: "abc1234".into(),
520        };
521        let v = serde_json::to_value(&commit).unwrap();
522        assert_eq!(v, json!({"type": "commit", "sha": "abc1234"}));
523        let parsed: RepositoryCheckout = serde_json::from_value(v).unwrap();
524        assert_eq!(parsed, commit);
525    }
526
527    #[test]
528    fn unknown_resource_type_falls_through_to_other() {
529        let raw = json!({"type": "future_resource", "blob": [1, 2]});
530        let parsed: SessionResource = serde_json::from_value(raw.clone()).unwrap();
531        match parsed {
532            SessionResource::Other(v) => assert_eq!(v, raw),
533            SessionResource::File(_)
534            | SessionResource::GitHubRepository(_)
535            | SessionResource::MemoryStore(_) => panic!("expected Other"),
536        }
537    }
538
539    #[tokio::test]
540    async fn list_resources_returns_typed_session_resources() {
541        let mock = MockServer::start().await;
542        Mock::given(method("GET"))
543            .and(path("/v1/sessions/sesn_x/resources"))
544            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
545                "data": [
546                    {"type": "file", "id": "sesrsc_a", "file_id": "file_01"},
547                    {"type": "github_repository", "id": "sesrsc_b", "url": "https://github.com/o/r"}
548                ],
549                "has_more": false
550            })))
551            .mount(&mock)
552            .await;
553
554        let client = client_for(&mock);
555        let page = client
556            .managed_agents()
557            .sessions()
558            .resources("sesn_x")
559            .list()
560            .await
561            .unwrap();
562        assert_eq!(page.data.len(), 2);
563        assert!(matches!(page.data[0], SessionResource::File(_)));
564        assert!(matches!(page.data[1], SessionResource::GitHubRepository(_)));
565    }
566
567    #[tokio::test]
568    async fn add_resource_posts_typed_payload() {
569        let mock = MockServer::start().await;
570        Mock::given(method("POST"))
571            .and(path("/v1/sessions/sesn_x/resources"))
572            .and(body_partial_json(json!({
573                "type": "file",
574                "file_id": "file_42"
575            })))
576            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
577                "type": "file",
578                "id": "sesrsc_42",
579                "file_id": "file_42"
580            })))
581            .mount(&mock)
582            .await;
583
584        let client = client_for(&mock);
585        let added = client
586            .managed_agents()
587            .sessions()
588            .resources("sesn_x")
589            .add(&SessionResource::File(FileResource::new("file_42")))
590            .await
591            .unwrap();
592        assert_eq!(added.id().unwrap(), "sesrsc_42");
593    }
594
595    #[tokio::test]
596    async fn update_resource_rotates_authorization_token() {
597        let mock = MockServer::start().await;
598        Mock::given(method("POST"))
599            .and(path("/v1/sessions/sesn_x/resources/sesrsc_b"))
600            .and(body_partial_json(json!({
601                "authorization_token": "ghp_new"
602            })))
603            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
604                "type": "github_repository",
605                "id": "sesrsc_b",
606                "url": "https://github.com/o/r"
607            })))
608            .mount(&mock)
609            .await;
610
611        let client = client_for(&mock);
612        let _ = client
613            .managed_agents()
614            .sessions()
615            .resources("sesn_x")
616            .update(
617                "sesrsc_b",
618                UpdateResourceRequest::rotate_authorization_token("ghp_new"),
619            )
620            .await
621            .unwrap();
622    }
623
624    #[tokio::test]
625    async fn delete_resource_returns_unit_on_success() {
626        let mock = MockServer::start().await;
627        Mock::given(method("DELETE"))
628            .and(path("/v1/sessions/sesn_x/resources/sesrsc_b"))
629            .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
630            .mount(&mock)
631            .await;
632
633        let client = client_for(&mock);
634        client
635            .managed_agents()
636            .sessions()
637            .resources("sesn_x")
638            .delete("sesrsc_b")
639            .await
640            .unwrap();
641    }
642
643    #[tokio::test]
644    async fn retrieve_resource_returns_typed_resource_by_id() {
645        let mock = MockServer::start().await;
646        Mock::given(method("GET"))
647            .and(path("/v1/sessions/sesn_x/resources/sesrsc_r"))
648            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
649                "id": "sesrsc_r",
650                "type": "file",
651                "file_id": "file_abc",
652                "mount_path": "/mnt/session/data.csv"
653            })))
654            .mount(&mock)
655            .await;
656
657        let client = client_for(&mock);
658        let r = client
659            .managed_agents()
660            .sessions()
661            .resources("sesn_x")
662            .retrieve("sesrsc_r")
663            .await
664            .unwrap();
665        match r {
666            SessionResource::File(f) => {
667                assert_eq!(f.id.as_deref(), Some("sesrsc_r"));
668                assert_eq!(f.file_id, "file_abc");
669            }
670            _ => panic!("expected File variant"),
671        }
672    }
673}