diff --git a/app/src/ai/agent_sdk/environment.rs b/app/src/ai/agent_sdk/environment.rs index 425cb999f89..348c8d1e23a 100644 --- a/app/src/ai/agent_sdk/environment.rs +++ b/app/src/ai/agent_sdk/environment.rs @@ -301,9 +301,12 @@ impl EnvironmentCommandRunner { println!("Description: {desc}"); } match &env.base_image { - BaseImage::DockerImage(img) => { + Some(BaseImage::DockerImage(img)) => { println!("Docker image: {img}"); } + None => { + println!("Docker image: None"); + } } if env.github_repos.is_empty() { println!("Repositories: None"); @@ -952,7 +955,7 @@ impl EnvironmentCommandRunner { } if let Some(new_docker_image) = docker_image { - updated_env.base_image = BaseImage::DockerImage(new_docker_image); + updated_env.base_image = Some(BaseImage::DockerImage(new_docker_image)); } for repo in add_repos { @@ -1121,7 +1124,8 @@ struct EnvironmentInfo { name: String, #[serde(skip_serializing_if = "Option::is_none")] description: Option, - base_image: BaseImage, + #[serde(skip_serializing_if = "Option::is_none")] + base_image: Option, github_repos: Vec, setup_commands: Vec, creator_email: String, @@ -1161,7 +1165,12 @@ impl TableFormat for EnvironmentInfo { Cell::new(&self.id), Cell::new(&self.name), Cell::new(description_display), - Cell::new(self.base_image.to_string()), + Cell::new( + self.base_image + .as_ref() + .map(BaseImage::to_string) + .unwrap_or_else(|| "None".to_string()), + ), Cell::new(github_repos_display), Cell::new(setup_commands_display), Cell::new(&self.creator_email), diff --git a/app/src/ai/conversation_details_panel.rs b/app/src/ai/conversation_details_panel.rs index 51ba6c9776f..cc082d6eaa1 100644 --- a/app/src/ai/conversation_details_panel.rs +++ b/app/src/ai/conversation_details_panel.rs @@ -1635,7 +1635,11 @@ impl ConversationDetailsPanel { app: &AppContext, ) -> Box { let environment_name = &env_model.name; - let docker_image = env_model.base_image.to_string(); + let docker_image = env_model + .base_image + .as_ref() + .map(|image| image.to_string()) + .unwrap_or_else(|| "None".to_string()); let theme = appearance.theme(); let ui_font_size = appearance.ui_font_size(); @@ -2263,7 +2267,13 @@ impl TypedActionView for ConversationDetailsPanel { if let Ok(server_id) = ServerId::try_from(env_id.as_str()) { let sync_id = SyncId::ServerId(server_id); if let Some(env) = CloudAmbientAgentEnvironment::get_by_id(&sync_id, ctx) { - let docker_image = env.model().string_model.base_image.to_string(); + let docker_image = env + .model() + .string_model + .base_image + .as_ref() + .map(|image| image.to_string()) + .unwrap_or_default(); ctx.clipboard() .write(ClipboardContent::plain_text(docker_image)); self.record_copy(CopyButtonKind::DockerImage, ctx); diff --git a/app/src/context_chips/display_menu.rs b/app/src/context_chips/display_menu.rs index 41513559d52..2fa932387f8 100644 --- a/app/src/context_chips/display_menu.rs +++ b/app/src/context_chips/display_menu.rs @@ -664,7 +664,13 @@ impl DisplayChipMenu { Some(EnvironmentSidecarData { name: env.model().string_model.display_name(), id: env.id.to_string(), - image: env.model().string_model.base_image.to_string(), + image: env + .model() + .string_model + .base_image + .as_ref() + .map(|image| image.to_string()) + .unwrap_or_else(|| "None".to_string()), repos_text, }) } diff --git a/app/src/settings_view/environments_page.rs b/app/src/settings_view/environments_page.rs index 84265303660..5b6faf34f37 100644 --- a/app/src/settings_view/environments_page.rs +++ b/app/src/settings_view/environments_page.rs @@ -144,7 +144,11 @@ impl EnvironmentDisplayData { id: env.id, name: model.name.clone(), description: model.description.clone(), - docker_image: model.base_image.to_string(), + docker_image: model + .base_image + .as_ref() + .map(|image| image.to_string()) + .unwrap_or_else(|| "None".to_string()), github_repos: model .github_repos .iter() @@ -270,7 +274,11 @@ impl EnvironmentsPageView { name: model.name.clone(), description: model.description.clone().unwrap_or_default(), selected_repos: model.github_repos.clone(), - docker_image: model.base_image.to_string(), + docker_image: model + .base_image + .as_ref() + .map(|image| image.to_string()) + .unwrap_or_default(), setup_commands: model.setup_commands.clone(), } }); diff --git a/crates/cloud_object_models/src/cloud_environment.rs b/crates/cloud_object_models/src/cloud_environment.rs index 0a23d4f6c13..bfe12f2bc05 100644 --- a/crates/cloud_object_models/src/cloud_environment.rs +++ b/crates/cloud_object_models/src/cloud_environment.rs @@ -122,6 +122,40 @@ impl fmt::Display for BaseImage { } } +/// Serde helpers for the `base_image` field of [`AmbientAgentEnvironment`]. +/// +/// The environment's base image is stored as a flat top-level `docker_image` +/// string in the on-wire JSON (not a nested object). These helpers flatten +/// `Option` to/from that string so that an environment without a +/// pinned image omits the key entirely (no `null`, no empty string) while an +/// environment with one preserves the exact legacy wire format. +mod base_image_field { + use serde::{Deserialize, Deserializer, Serializer}; + + use super::BaseImage; + + pub fn serialize(value: &Option, serializer: S) -> Result + where + S: Serializer, + { + match value { + Some(BaseImage::DockerImage(image)) => serializer.serialize_str(image), + // `skip_serializing_if = "Option::is_none"` on the field prevents + // this arm from running for stored environments; handle it for + // safety anyway. + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let image = Option::::deserialize(deserializer)?; + Ok(image.map(BaseImage::DockerImage)) + } +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct GcpProviderConfig { pub project_number: String, @@ -181,9 +215,20 @@ pub struct AmbientAgentEnvironment { /// `github_repos`. #[serde(default, skip_serializing_if = "Option::is_none")] pub source_repos: Option>, - /// Base image specification - #[serde(flatten)] - pub base_image: BaseImage, + /// Base image specification. + /// + /// `None` indicates that no base image is pinned for this environment. + /// Serializes as a top-level `docker_image` string when present and is + /// omitted entirely when absent, preserving the legacy wire format for + /// environments that pin an image while allowing environments without one + /// to deserialize successfully. + #[serde( + rename = "docker_image", + default, + skip_serializing_if = "Option::is_none", + with = "base_image_field" + )] + pub base_image: Option, /// List of setup commands to run after cloning #[serde(default)] pub setup_commands: Vec, @@ -212,7 +257,7 @@ impl AmbientAgentEnvironment { code_forge: None, github_repos, source_repos: None, - base_image: BaseImage::DockerImage(docker_image), + base_image: Some(BaseImage::DockerImage(docker_image)), setup_commands, providers: ProvidersConfig::default(), secrets: None, diff --git a/crates/cloud_object_models/src/cloud_environment_tests.rs b/crates/cloud_object_models/src/cloud_environment_tests.rs index 74352cdc331..dad92cbc0aa 100644 --- a/crates/cloud_object_models/src/cloud_environment_tests.rs +++ b/crates/cloud_object_models/src/cloud_environment_tests.rs @@ -28,7 +28,7 @@ fn deserialize_legacy_environment_without_providers() { ); assert_eq!( env.base_image, - BaseImage::DockerImage("ubuntu:latest".into()) + Some(BaseImage::DockerImage("ubuntu:latest".into())) ); assert_eq!(env.setup_commands, vec!["echo hello"]); } @@ -346,3 +346,102 @@ fn roundtrip_serde_with_secrets() { let deserialized: AmbientAgentEnvironment = serde_json::from_str(&serialized).unwrap(); assert_eq!(env, deserialized); } + +#[test] +fn deserialize_environment_without_docker_image() { + let json = serde_json::json!({ + "name": "no-image-env", + "github_repos": [{"owner": "warpdotdev", "repo": "warp"}], + "setup_commands": ["echo hello"] + }); + + let env: AmbientAgentEnvironment = serde_json::from_value(json).unwrap(); + assert_eq!(env.name, "no-image-env"); + assert_eq!(env.base_image, None); + assert_eq!(env.setup_commands, vec!["echo hello"]); +} + +#[test] +fn deserialize_environment_with_null_docker_image_is_none() { + // A server-supplied explicit `null` is treated the same as a missing key. + let json = serde_json::json!({ + "name": "null-image-env", + "github_repos": [], + "docker_image": null + }); + + let env: AmbientAgentEnvironment = serde_json::from_value(json).unwrap(); + assert_eq!(env.base_image, None); +} + +#[test] +fn serialize_environment_without_docker_image_omits_key() { + let mut env = AmbientAgentEnvironment::new( + "no-image-env".into(), + None, + vec![GithubRepo::new("warpdotdev".into(), "warp".into())], + "unused".into(), + vec!["echo hello".into()], + ); + env.base_image = None; + + let json = serde_json::to_value(&env).unwrap(); + let obj = json.as_object().unwrap(); + assert!(!obj.contains_key("docker_image")); + // No nested `base_image` object should leak either. + assert!(!obj.contains_key("base_image")); +} + +#[test] +fn serialize_environment_with_docker_image_emits_legacy_wire_format() { + let env = AmbientAgentEnvironment::new( + "with-image-env".into(), + None, + vec![GithubRepo::new("warpdotdev".into(), "warp".into())], + "ubuntu:latest".into(), + vec!["echo hello".into()], + ); + + let json = serde_json::to_value(&env).unwrap(); + let obj = json.as_object().unwrap(); + // Exactly the legacy flat top-level string key — no nested object. + assert_eq!(obj.get("docker_image").unwrap(), "ubuntu:latest"); + assert!(!obj.contains_key("base_image")); +} + +#[test] +fn roundtrip_environment_without_docker_image() { + let mut env = AmbientAgentEnvironment::new( + "rt-no-image".into(), + Some("desc".into()), + vec![GithubRepo::new("owner".into(), "repo".into())], + "ignored".into(), + vec!["make build".into()], + ); + env.base_image = None; + + let serialized = serde_json::to_string(&env).unwrap(); + let json: serde_json::Value = serde_json::from_str(&serialized).unwrap(); + assert!(!json.as_object().unwrap().contains_key("docker_image")); + + let deserialized: AmbientAgentEnvironment = serde_json::from_str(&serialized).unwrap(); + assert_eq!(env, deserialized); +} + +#[test] +fn roundtrip_environment_with_docker_image() { + let env = AmbientAgentEnvironment::new( + "rt-with-image".into(), + Some("desc".into()), + vec![GithubRepo::new("owner".into(), "repo".into())], + "ubuntu:latest".into(), + vec!["make build".into()], + ); + + let serialized = serde_json::to_string(&env).unwrap(); + let json: serde_json::Value = serde_json::from_str(&serialized).unwrap(); + assert_eq!(json.get("docker_image").unwrap(), "ubuntu:latest"); + + let deserialized: AmbientAgentEnvironment = serde_json::from_str(&serialized).unwrap(); + assert_eq!(env, deserialized); +}