Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions app/src/ai/agent_sdk/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1121,7 +1124,8 @@ struct EnvironmentInfo {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
base_image: BaseImage,
#[serde(skip_serializing_if = "Option::is_none")]
base_image: Option<BaseImage>,
github_repos: Vec<GithubRepo>,
setup_commands: Vec<String>,
creator_email: String,
Expand Down Expand Up @@ -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),
Expand Down
14 changes: 12 additions & 2 deletions app/src/ai/conversation_details_panel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1635,7 +1635,11 @@ impl ConversationDetailsPanel {
app: &AppContext,
) -> Box<dyn Element> {
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();
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 7 additions & 1 deletion app/src/context_chips/display_menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}
Expand Down
12 changes: 10 additions & 2 deletions app/src/settings_view/environments_page.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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(),
}
});
Expand Down
53 changes: 49 additions & 4 deletions crates/cloud_object_models/src/cloud_environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<BaseImage>` 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<S>(value: &Option<BaseImage>, serializer: S) -> Result<S::Ok, S::Error>
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<Option<BaseImage>, D::Error>
where
D: Deserializer<'de>,
{
let image = Option::<String>::deserialize(deserializer)?;
Ok(image.map(BaseImage::DockerImage))
}
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct GcpProviderConfig {
pub project_number: String,
Expand Down Expand Up @@ -181,9 +215,20 @@ pub struct AmbientAgentEnvironment {
/// `github_repos`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_repos: Option<Vec<SourceRepo>>,
/// 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<BaseImage>,
/// List of setup commands to run after cloning
#[serde(default)]
pub setup_commands: Vec<String>,
Expand Down Expand Up @@ -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,
Expand Down
101 changes: 100 additions & 1 deletion crates/cloud_object_models/src/cloud_environment_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
}
Expand Down Expand Up @@ -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);
}