use serde::{Deserialize, Serialize};
use crate::client::Client;
use crate::error::Result;
use crate::pagination::Paginated;
use super::MANAGED_AGENTS_BETA;
#[derive(Debug, Clone, PartialEq)]
pub enum SessionResource {
File(FileResource),
GitHubRepository(GitHubRepositoryResource),
MemoryStore(MemoryStoreResource),
Other(serde_json::Value),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct FileResource {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub file_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mount_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
}
impl FileResource {
#[must_use]
pub fn new(file_id: impl Into<String>) -> Self {
Self {
id: None,
file_id: file_id.into(),
mount_path: None,
created_at: None,
updated_at: None,
}
}
#[must_use]
pub fn mount_path(mut self, path: impl Into<String>) -> Self {
self.mount_path = Some(path.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum RepositoryCheckout {
Branch {
name: String,
},
Commit {
sha: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct GitHubRepositoryResource {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mount_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checkout: Option<RepositoryCheckout>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authorization_token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
}
impl GitHubRepositoryResource {
#[must_use]
pub fn new(url: impl Into<String>, authorization_token: impl Into<String>) -> Self {
Self {
id: None,
url: url.into(),
mount_path: None,
checkout: None,
authorization_token: Some(authorization_token.into()),
created_at: None,
updated_at: None,
}
}
#[must_use]
pub fn checkout(mut self, checkout: RepositoryCheckout) -> Self {
self.checkout = Some(checkout);
self
}
#[must_use]
pub fn mount_path(mut self, path: impl Into<String>) -> Self {
self.mount_path = Some(path.into());
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum MemoryStoreAccess {
ReadOnly,
ReadWrite,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct MemoryStoreResource {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub memory_store_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mount_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub access: Option<MemoryStoreAccess>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
}
impl MemoryStoreResource {
#[must_use]
pub fn new(memory_store_id: impl Into<String>) -> Self {
Self {
id: None,
memory_store_id: memory_store_id.into(),
name: None,
description: None,
mount_path: None,
access: None,
instructions: None,
}
}
#[must_use]
pub fn mount_path(mut self, path: impl Into<String>) -> Self {
self.mount_path = Some(path.into());
self
}
#[must_use]
pub fn access(mut self, access: MemoryStoreAccess) -> Self {
self.access = Some(access);
self
}
#[must_use]
pub fn instructions(mut self, instructions: impl Into<String>) -> Self {
self.instructions = Some(instructions.into());
self
}
}
const KNOWN_RESOURCE_TAGS: &[&str] = &["file", "github_repository", "memory_store"];
impl Serialize for SessionResource {
fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
match self {
Self::File(r) => {
let mut map = s.serialize_map(None)?;
map.serialize_entry("type", "file")?;
if let Some(id) = &r.id {
map.serialize_entry("id", id)?;
}
map.serialize_entry("file_id", &r.file_id)?;
if let Some(mp) = &r.mount_path {
map.serialize_entry("mount_path", mp)?;
}
map.end()
}
Self::GitHubRepository(r) => {
let mut map = s.serialize_map(None)?;
map.serialize_entry("type", "github_repository")?;
if let Some(id) = &r.id {
map.serialize_entry("id", id)?;
}
map.serialize_entry("url", &r.url)?;
if let Some(mp) = &r.mount_path {
map.serialize_entry("mount_path", mp)?;
}
if let Some(t) = &r.authorization_token {
map.serialize_entry("authorization_token", t)?;
}
map.end()
}
Self::MemoryStore(r) => {
let mut map = s.serialize_map(None)?;
map.serialize_entry("type", "memory_store")?;
if let Some(id) = &r.id {
map.serialize_entry("id", id)?;
}
map.serialize_entry("memory_store_id", &r.memory_store_id)?;
if let Some(a) = r.access {
map.serialize_entry("access", &a)?;
}
if let Some(i) = &r.instructions {
map.serialize_entry("instructions", i)?;
}
map.end()
}
Self::Other(v) => v.serialize(s),
}
}
}
impl<'de> Deserialize<'de> for SessionResource {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
let raw = serde_json::Value::deserialize(d)?;
let tag = raw.get("type").and_then(serde_json::Value::as_str);
match tag {
Some("file") if KNOWN_RESOURCE_TAGS.contains(&"file") => {
let r = serde_json::from_value::<FileResource>(raw)
.map_err(serde::de::Error::custom)?;
Ok(Self::File(r))
}
Some("github_repository") => {
let r = serde_json::from_value::<GitHubRepositoryResource>(raw)
.map_err(serde::de::Error::custom)?;
Ok(Self::GitHubRepository(r))
}
Some("memory_store") => {
let r = serde_json::from_value::<MemoryStoreResource>(raw)
.map_err(serde::de::Error::custom)?;
Ok(Self::MemoryStore(r))
}
_ => Ok(Self::Other(raw)),
}
}
}
impl SessionResource {
#[must_use]
pub fn id(&self) -> Option<&str> {
match self {
Self::File(r) => r.id.as_deref(),
Self::GitHubRepository(r) => r.id.as_deref(),
Self::MemoryStore(r) => r.id.as_deref(),
Self::Other(v) => v.get("id").and_then(serde_json::Value::as_str),
}
}
}
#[derive(Debug, Clone, Default, Serialize)]
#[non_exhaustive]
pub struct UpdateResourceRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub authorization_token: Option<String>,
}
impl UpdateResourceRequest {
#[must_use]
pub fn rotate_authorization_token(token: impl Into<String>) -> Self {
Self {
authorization_token: Some(token.into()),
}
}
}
pub struct Resources<'a> {
pub(crate) client: &'a Client,
pub(crate) session_id: String,
}
impl Resources<'_> {
pub async fn list(&self) -> Result<Paginated<SessionResource>> {
let path = format!("/v1/sessions/{}/resources", self.session_id);
self.client
.execute_with_retry(
|| self.client.request_builder(reqwest::Method::GET, &path),
&[MANAGED_AGENTS_BETA],
)
.await
}
pub async fn retrieve(&self, resource_id: &str) -> Result<SessionResource> {
let path = format!("/v1/sessions/{}/resources/{resource_id}", self.session_id);
self.client
.execute_with_retry(
|| self.client.request_builder(reqwest::Method::GET, &path),
&[MANAGED_AGENTS_BETA],
)
.await
}
pub async fn add(&self, resource: &SessionResource) -> Result<SessionResource> {
let path = format!("/v1/sessions/{}/resources", self.session_id);
let body = resource;
self.client
.execute_with_retry(
|| {
self.client
.request_builder(reqwest::Method::POST, &path)
.json(body)
},
&[MANAGED_AGENTS_BETA],
)
.await
}
pub async fn update(
&self,
resource_id: &str,
request: UpdateResourceRequest,
) -> Result<SessionResource> {
let path = format!("/v1/sessions/{}/resources/{resource_id}", self.session_id);
let body = &request;
self.client
.execute_with_retry(
|| {
self.client
.request_builder(reqwest::Method::POST, &path)
.json(body)
},
&[MANAGED_AGENTS_BETA],
)
.await
}
pub async fn delete(&self, resource_id: &str) -> Result<()> {
let path = format!("/v1/sessions/{}/resources/{resource_id}", self.session_id);
let _: serde_json::Value = self
.client
.execute_with_retry(
|| self.client.request_builder(reqwest::Method::DELETE, &path),
&[MANAGED_AGENTS_BETA],
)
.await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use serde_json::json;
use wiremock::matchers::{body_partial_json, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn client_for(mock: &MockServer) -> Client {
Client::builder()
.api_key("sk-ant-test")
.base_url(mock.uri())
.build()
.unwrap()
}
#[test]
fn file_resource_round_trips_with_mount_path() {
let r =
SessionResource::File(FileResource::new("file_01").mount_path("/workspace/data.csv"));
let v = serde_json::to_value(&r).unwrap();
assert_eq!(
v,
json!({
"type": "file",
"file_id": "file_01",
"mount_path": "/workspace/data.csv"
})
);
let parsed: SessionResource = serde_json::from_value(v).unwrap();
assert_eq!(parsed, r);
}
#[test]
fn github_resource_serializes_authorization_token_on_create() {
let r = SessionResource::GitHubRepository(
GitHubRepositoryResource::new("https://github.com/org/repo", "ghp_xxx")
.mount_path("/workspace/repo"),
);
let v = serde_json::to_value(&r).unwrap();
assert_eq!(v["authorization_token"], "ghp_xxx");
assert_eq!(v["mount_path"], "/workspace/repo");
}
#[test]
fn memory_store_resource_round_trips_with_access_and_instructions() {
let r = SessionResource::MemoryStore(
MemoryStoreResource::new("memstore_01")
.access(MemoryStoreAccess::ReadOnly)
.instructions("Reference only."),
);
let v = serde_json::to_value(&r).unwrap();
assert_eq!(
v,
json!({
"type": "memory_store",
"memory_store_id": "memstore_01",
"access": "read_only",
"instructions": "Reference only."
})
);
let parsed: SessionResource = serde_json::from_value(v).unwrap();
assert_eq!(parsed, r);
}
#[test]
fn repository_checkout_round_trips_branch_and_commit() {
let branch = RepositoryCheckout::Branch {
name: "main".into(),
};
let v = serde_json::to_value(&branch).unwrap();
assert_eq!(v, json!({"type": "branch", "name": "main"}));
let parsed: RepositoryCheckout = serde_json::from_value(v).unwrap();
assert_eq!(parsed, branch);
let commit = RepositoryCheckout::Commit {
sha: "abc1234".into(),
};
let v = serde_json::to_value(&commit).unwrap();
assert_eq!(v, json!({"type": "commit", "sha": "abc1234"}));
let parsed: RepositoryCheckout = serde_json::from_value(v).unwrap();
assert_eq!(parsed, commit);
}
#[test]
fn unknown_resource_type_falls_through_to_other() {
let raw = json!({"type": "future_resource", "blob": [1, 2]});
let parsed: SessionResource = serde_json::from_value(raw.clone()).unwrap();
match parsed {
SessionResource::Other(v) => assert_eq!(v, raw),
SessionResource::File(_)
| SessionResource::GitHubRepository(_)
| SessionResource::MemoryStore(_) => panic!("expected Other"),
}
}
#[tokio::test]
async fn list_resources_returns_typed_session_resources() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/sessions/sesn_x/resources"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [
{"type": "file", "id": "sesrsc_a", "file_id": "file_01"},
{"type": "github_repository", "id": "sesrsc_b", "url": "https://github.com/o/r"}
],
"has_more": false
})))
.mount(&mock)
.await;
let client = client_for(&mock);
let page = client
.managed_agents()
.sessions()
.resources("sesn_x")
.list()
.await
.unwrap();
assert_eq!(page.data.len(), 2);
assert!(matches!(page.data[0], SessionResource::File(_)));
assert!(matches!(page.data[1], SessionResource::GitHubRepository(_)));
}
#[tokio::test]
async fn add_resource_posts_typed_payload() {
let mock = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/sessions/sesn_x/resources"))
.and(body_partial_json(json!({
"type": "file",
"file_id": "file_42"
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"type": "file",
"id": "sesrsc_42",
"file_id": "file_42"
})))
.mount(&mock)
.await;
let client = client_for(&mock);
let added = client
.managed_agents()
.sessions()
.resources("sesn_x")
.add(&SessionResource::File(FileResource::new("file_42")))
.await
.unwrap();
assert_eq!(added.id().unwrap(), "sesrsc_42");
}
#[tokio::test]
async fn update_resource_rotates_authorization_token() {
let mock = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/sessions/sesn_x/resources/sesrsc_b"))
.and(body_partial_json(json!({
"authorization_token": "ghp_new"
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"type": "github_repository",
"id": "sesrsc_b",
"url": "https://github.com/o/r"
})))
.mount(&mock)
.await;
let client = client_for(&mock);
let _ = client
.managed_agents()
.sessions()
.resources("sesn_x")
.update(
"sesrsc_b",
UpdateResourceRequest::rotate_authorization_token("ghp_new"),
)
.await
.unwrap();
}
#[tokio::test]
async fn delete_resource_returns_unit_on_success() {
let mock = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/v1/sessions/sesn_x/resources/sesrsc_b"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
.mount(&mock)
.await;
let client = client_for(&mock);
client
.managed_agents()
.sessions()
.resources("sesn_x")
.delete("sesrsc_b")
.await
.unwrap();
}
#[tokio::test]
async fn retrieve_resource_returns_typed_resource_by_id() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/sessions/sesn_x/resources/sesrsc_r"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "sesrsc_r",
"type": "file",
"file_id": "file_abc",
"mount_path": "/mnt/session/data.csv"
})))
.mount(&mock)
.await;
let client = client_for(&mock);
let r = client
.managed_agents()
.sessions()
.resources("sesn_x")
.retrieve("sesrsc_r")
.await
.unwrap();
match r {
SessionResource::File(f) => {
assert_eq!(f.id.as_deref(), Some("sesrsc_r"));
assert_eq!(f.file_id, "file_abc");
}
_ => panic!("expected File variant"),
}
}
}