1use serde::{Deserialize, Serialize};
14
15use crate::client::Client;
16use crate::error::Result;
17use crate::pagination::Paginated;
18
19use super::MANAGED_AGENTS_BETA;
20
21#[derive(Debug, Clone, PartialEq)]
30pub enum SessionResource {
31 File(FileResource),
33 GitHubRepository(GitHubRepositoryResource),
35 MemoryStore(MemoryStoreResource),
37 Other(serde_json::Value),
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[non_exhaustive]
44pub struct FileResource {
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub id: Option<String>,
48 pub file_id: String,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub mount_path: Option<String>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub created_at: Option<String>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub updated_at: Option<String>,
61}
62
63impl FileResource {
64 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(tag = "type", rename_all = "snake_case")]
88#[non_exhaustive]
89pub enum RepositoryCheckout {
90 Branch {
92 name: String,
94 },
95 Commit {
97 sha: String,
99 },
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[non_exhaustive]
105pub struct GitHubRepositoryResource {
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub id: Option<String>,
109 pub url: String,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub mount_path: Option<String>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub checkout: Option<RepositoryCheckout>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub authorization_token: Option<String>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub created_at: Option<String>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub updated_at: Option<String>,
128}
129
130impl GitHubRepositoryResource {
131 #[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 #[must_use]
147 pub fn checkout(mut self, checkout: RepositoryCheckout) -> Self {
148 self.checkout = Some(checkout);
149 self
150 }
151
152 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163#[non_exhaustive]
164pub enum MemoryStoreAccess {
165 ReadOnly,
167 ReadWrite,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174#[non_exhaustive]
175pub struct MemoryStoreResource {
176 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub id: Option<String>,
182 pub memory_store_id: String,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub name: Option<String>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub description: Option<String>,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub mount_path: Option<String>,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub access: Option<MemoryStoreAccess>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub instructions: Option<String>,
200}
201
202impl MemoryStoreResource {
203 #[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 #[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 #[must_use]
226 pub fn access(mut self, access: MemoryStoreAccess) -> Self {
227 self.access = Some(access);
228 self
229 }
230
231 #[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 #[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#[derive(Debug, Clone, Default, Serialize)]
337#[non_exhaustive]
338pub struct UpdateResourceRequest {
339 #[serde(skip_serializing_if = "Option::is_none")]
341 pub authorization_token: Option<String>,
342}
343
344impl UpdateResourceRequest {
345 #[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
354pub struct Resources<'a> {
363 pub(crate) client: &'a Client,
364 pub(crate) session_id: String,
365}
366
367impl Resources<'_> {
368 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 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 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 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 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}