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(ToString::to_string)
.unwrap_or_default(),
),
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(ToString::to_string)
.unwrap_or_default();

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(ToString::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(ToString::to_string)
.unwrap_or_default(),
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(ToString::to_string)
.unwrap_or_default(),
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(ToString::to_string)
.unwrap_or_default(),
setup_commands: model.setup_commands.clone(),
}
});
Expand Down
12 changes: 9 additions & 3 deletions crates/cloud_object_models/src/cloud_environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,14 @@ pub struct AmbientAgentEnvironment {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_repos: Option<Vec<SourceRepo>>,
/// Base image specification
#[serde(flatten)]
pub base_image: BaseImage,
///
/// `None` represents an environment that does not pin a base image. The
/// server may store environments without a `docker_image` field, which must
/// still load in the client. `None` serializes to an absent `docker_image`
/// key, so the wire format for environments that do pin an image is
/// unchanged (`{"docker_image": "..."}`).
#[serde(default, flatten, skip_serializing_if = "Option::is_none")]
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 +218,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
103 changes: 102 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,11 +28,112 @@ 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"]);
}

#[test]
fn deserialize_environment_without_docker_image() {
// The server may store environments that do not pin a base image. Such an
// environment has no `docker_image` field and must still load in the client.
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"]);
assert_eq!(
env.effective_repos(),
vec![SourceRepo::new(
CodeForge::GitHub,
"warpdotdev".into(),
"warp".into()
)]
);
}

#[test]
fn environment_without_docker_image_preserves_other_fields() {
let json = serde_json::json!({
"name": "no-image-env",
"description": "desc",
"code_forge": "GITLAB",
"source_repos": [{"owner": "platform/backend", "repo": "api"}],
"secrets": [{"name": "GH_TOKEN"}]
});

let env: AmbientAgentEnvironment = serde_json::from_value(json).unwrap();
assert_eq!(env.base_image, None);
assert_eq!(env.description.as_deref(), Some("desc"));
assert_eq!(env.effective_code_forge(), CodeForge::GitLab);
let expected_secrets = vec![EnvironmentSecretRef {
name: "GH_TOKEN".into(),
}];
assert_eq!(env.secrets.as_deref(), Some(expected_secrets.as_slice()));
}

#[test]
fn serialize_environment_without_docker_image_omits_field() {
let mut env = AmbientAgentEnvironment::new(
"no-image-env".into(),
None,
vec![GithubRepo::new("warpdotdev".into(), "warp".into())],
"ubuntu:latest".into(),
vec![],
);
env.base_image = None;

let json = serde_json::to_value(&env).unwrap();
assert!(
!json.as_object().unwrap().contains_key("docker_image"),
"serializing an environment without a base image must not emit a docker_image key: {json}"
);
}

#[test]
fn serialize_environment_with_docker_image_preserves_wire_format() {
let env = AmbientAgentEnvironment::new(
"with-image-env".into(),
None,
vec![],
"ubuntu:latest".into(),
vec![],
);

let json = serde_json::to_value(&env).unwrap();
// The flattened enum must keep producing the exact existing wire format:
// a top-level `docker_image` string, not a nested object.
assert_eq!(
json.get("docker_image").and_then(|v| v.as_str()),
Some("ubuntu:latest")
);
}

#[test]
fn roundtrip_serde_without_docker_image() {
let mut env = AmbientAgentEnvironment::new(
"no-image-rt".into(),
Some("desc".into()),
vec![GithubRepo::new("owner".into(), "repo".into())],
"alpine:latest".into(),
vec!["make build".into()],
);
env.base_image = None;

let serialized = serde_json::to_string(&env).unwrap();
let deserialized: AmbientAgentEnvironment = serde_json::from_str(&serialized).unwrap();
assert_eq!(env, deserialized);
assert!(
!serialized.contains("docker_image"),
"roundtripped JSON must not contain docker_image: {serialized}"
);
}

#[test]
fn deserialize_gitlab_environment_uses_authoritative_source_repos() {
let json = serde_json::json!({
Expand Down