feat: presentation artifact p1 (#13341)

Part 1 of presentation tool artifact
This commit is contained in:
jif-oai
2026-03-03 11:38:03 +00:00
committed by GitHub
Unverified
parent 07e532dcb9
commit 4874b9291a
18 changed files with 6755 additions and 61 deletions
+41
View File
File diff suppressed because one or more lines are too long
+570 -61
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -33,6 +33,7 @@ members = [
"mcp-server",
"network-proxy",
"ollama",
"artifact-presentation",
"process-hardening",
"protocol",
"rmcp-client",
@@ -109,6 +110,7 @@ codex-mcp-server = { path = "mcp-server" }
codex-network-proxy = { path = "network-proxy" }
codex-ollama = { path = "ollama" }
codex-otel = { path = "otel" }
codex-artifact-presentation = { path = "artifact-presentation" }
codex-process-hardening = { path = "process-hardening" }
codex-protocol = { path = "protocol" }
codex-responses-api-proxy = { path = "responses-api-proxy" }
@@ -215,6 +217,7 @@ owo-colors = "4.3.0"
path-absolutize = "3.1.1"
pathdiff = "0.2"
portable-pty = "0.9.0"
ppt-rs = "0.2.6"
predicates = "3"
pretty_assertions = "1.4.1"
pulldown-cmark = "0.10"
@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "artifact-presentation",
crate_name = "codex_artifact_presentation",
)
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "codex-artifact-presentation"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
name = "codex_artifact_presentation"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
base64 = { workspace = true }
image = { workspace = true, features = ["jpeg", "png"] }
ppt-rs = { workspace = true }
reqwest = { workspace = true, features = ["blocking"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
tiny_http = { workspace = true }
@@ -0,0 +1,6 @@
mod presentation_artifact;
#[cfg(test)]
mod tests;
pub use presentation_artifact::*;
File diff suppressed because it is too large Load Diff
+952
View File
@@ -0,0 +1,952 @@
use super::presentation_artifact::*;
use pretty_assertions::assert_eq;
#[test]
fn manager_can_create_add_text_and_export() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let mut manager = PresentationArtifactManager::default();
let create_response = manager.execute(
PresentationArtifactRequest {
artifact_id: None,
action: "create".to_string(),
args: serde_json::json!({ "name": "Demo" }),
},
temp_dir.path(),
)?;
let artifact_id = create_response.artifact_id;
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "add_slide".to_string(),
args: serde_json::json!({}),
},
temp_dir.path(),
)?;
let add_text = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "add_text_shape".to_string(),
args: serde_json::json!({
"slide_index": 0,
"text": "hello",
"position": { "left": 40, "top": 40, "width": 200, "height": 80 }
}),
},
temp_dir.path(),
)?;
assert_eq!(
add_text
.artifact_snapshot
.as_ref()
.map(|snapshot| snapshot.slide_count),
Some(1)
);
let export_path = temp_dir.path().join("deck.pptx");
let export = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id),
action: "export_pptx".to_string(),
args: serde_json::json!({ "path": export_path }),
},
temp_dir.path(),
)?;
assert_eq!(export.exported_paths.len(), 1);
assert!(export.exported_paths[0].exists());
Ok(())
}
#[test]
fn manager_can_import_exported_presentation() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let mut manager = PresentationArtifactManager::default();
let created = manager.execute(
PresentationArtifactRequest {
artifact_id: None,
action: "create".to_string(),
args: serde_json::json!({ "name": "Roundtrip" }),
},
temp_dir.path(),
)?;
let artifact_id = created.artifact_id.clone();
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "add_slide".to_string(),
args: serde_json::json!({}),
},
temp_dir.path(),
)?;
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id),
action: "add_shape".to_string(),
args: serde_json::json!({
"slide_index": 0,
"geometry": "rectangle",
"position": { "left": 24, "top": 24, "width": 180, "height": 120 },
"text": "shape"
}),
},
temp_dir.path(),
)?;
let export_path = temp_dir.path().join("roundtrip.pptx");
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(created.artifact_id),
action: "export_pptx".to_string(),
args: serde_json::json!({ "path": export_path }),
},
temp_dir.path(),
)?;
let imported = manager.execute(
PresentationArtifactRequest {
artifact_id: None,
action: "import_pptx".to_string(),
args: serde_json::json!({ "path": "roundtrip.pptx" }),
},
temp_dir.path(),
)?;
assert_eq!(
imported
.artifact_snapshot
.as_ref()
.map(|snapshot| snapshot.slide_count),
Some(1)
);
Ok(())
}
#[test]
fn image_fit_contain_preserves_aspect_ratio() {
let image = ImageElement {
element_id: "element_1".to_string(),
frame: Rect {
left: 10,
top: 10,
width: 200,
height: 200,
},
payload: Some(ImagePayload {
bytes: Vec::new(),
format: "PNG".to_string(),
width_px: 400,
height_px: 200,
}),
fit_mode: ImageFitMode::Contain,
crop: None,
lock_aspect_ratio: true,
alt_text: None,
prompt: None,
is_placeholder: false,
z_order: 0,
};
let (left, top, width, height, crop) = fit_image(&image);
assert_eq!((left, top, width, height), (10, 60, 200, 100));
assert_eq!(crop, None);
}
#[test]
fn preview_image_writer_supports_jpeg_and_scale() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let source_path = temp_dir.path().join("preview.png");
image::RgbaImage::from_pixel(80, 40, image::Rgba([0x22, 0x66, 0xAA, 0xFF]))
.save(&source_path)?;
let target_path = temp_dir.path().join("preview.jpg");
write_preview_image(
&source_path,
&target_path,
PreviewOutputFormat::Jpeg,
0.5,
82,
"test",
)?;
let rendered = image::open(&target_path)?;
assert_eq!((rendered.width(), rendered.height()), (40, 20));
assert_eq!(
image::ImageFormat::from_path(&target_path)?,
image::ImageFormat::Jpeg
);
assert!(!source_path.exists());
Ok(())
}
#[test]
fn image_uris_can_add_and_replace_images() -> Result<(), Box<dyn std::error::Error>> {
let mut image_bytes = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
16,
8,
image::Rgba([0x11, 0x88, 0xCC, 0xFF]),
))
.write_to(&mut image_bytes, image::ImageFormat::Png)?;
let png = image_bytes.into_inner();
let server = tiny_http::Server::http("127.0.0.1:0").expect("server");
let port = server.server_addr().to_ip().expect("ip addr").port();
let server_thread = std::thread::spawn(move || {
for request in server.incoming_requests().take(2) {
let response = tiny_http::Response::from_data(png.clone()).with_header(
tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"image/png"[..])
.expect("header"),
);
request.respond(response).expect("respond");
}
});
let temp_dir = tempfile::tempdir()?;
let mut manager = PresentationArtifactManager::default();
let created = manager.execute(
PresentationArtifactRequest {
artifact_id: None,
action: "create".to_string(),
args: serde_json::json!({ "name": "Remote Images" }),
},
temp_dir.path(),
)?;
let artifact_id = created.artifact_id;
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "add_slide".to_string(),
args: serde_json::json!({}),
},
temp_dir.path(),
)?;
let remote_uri = format!("http://127.0.0.1:{port}/image.png");
let added = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "add_image".to_string(),
args: serde_json::json!({
"slide_index": 0,
"uri": remote_uri,
"position": { "left": 32, "top": 48, "width": 120, "height": 60 }
}),
},
temp_dir.path(),
)?;
let element_id = added
.artifact_snapshot
.as_ref()
.and_then(|snapshot| snapshot.slides.first())
.and_then(|slide| slide.element_ids.last())
.cloned()
.expect("image id");
assert_eq!(
added
.artifact_snapshot
.as_ref()
.and_then(|snapshot| snapshot.slides.first())
.map(|slide| slide.element_types.clone()),
Some(vec!["image".to_string()])
);
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "replace_image".to_string(),
args: serde_json::json!({
"element_id": format!("im/{element_id}"),
"uri": format!("http://127.0.0.1:{port}/updated.png"),
"fit": "contain"
}),
},
temp_dir.path(),
)?;
let inspect = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id),
action: "inspect".to_string(),
args: serde_json::json!({ "kind": "image" }),
},
temp_dir.path(),
)?;
assert!(
inspect
.inspect_ndjson
.expect("image inspect")
.contains("\"fit\":\"Contain\"")
);
server_thread.join().expect("server thread");
Ok(())
}
#[test]
fn manager_supports_layout_theme_notes_and_inspect() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let mut manager = PresentationArtifactManager::default();
let created = manager.execute(
PresentationArtifactRequest {
artifact_id: None,
action: "create".to_string(),
args: serde_json::json!({
"name": "Deck",
"theme": {
"color_scheme": {
"accent1": "#123456",
"bg1": "#FFFFFF",
"tx1": "#111111"
},
"major_font": "Aptos"
}
}),
},
temp_dir.path(),
)?;
let artifact_id = created.artifact_id.clone();
let master_layouts = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "create_layout".to_string(),
args: serde_json::json!({ "name": "Brand Master", "kind": "master" }),
},
temp_dir.path(),
)?;
assert_eq!(master_layouts.layout_list.as_ref().map(Vec::len), Some(1));
let master_id = master_layouts.layout_list.unwrap()[0].layout_id.clone();
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "add_layout_placeholder".to_string(),
args: serde_json::json!({
"layout_id": master_id,
"name": "title",
"placeholder_type": "title",
"text": "Placeholder title",
"position": { "left": 48, "top": 48, "width": 500, "height": 60 }
}),
},
temp_dir.path(),
)?;
let child_layouts = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "create_layout".to_string(),
args: serde_json::json!({
"name": "Title Slide",
"kind": "layout",
"parent_layout_id": master_id
}),
},
temp_dir.path(),
)?;
assert_eq!(child_layouts.layout_list.as_ref().map(Vec::len), Some(2));
let layout_id = child_layouts
.layout_list
.as_ref()
.and_then(|layouts| layouts.iter().find(|layout| layout.kind == "layout"))
.map(|layout| layout.layout_id.clone())
.expect("child layout id");
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "add_layout_placeholder".to_string(),
args: serde_json::json!({
"layout_id": layout_id,
"name": "subtitle",
"placeholder_type": "subtitle",
"text": "Placeholder subtitle",
"position": { "left": 48, "top": 128, "width": 500, "height": 48 }
}),
},
temp_dir.path(),
)?;
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "add_slide".to_string(),
args: serde_json::json!({ "layout": layout_id }),
},
temp_dir.path(),
)?;
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "set_notes".to_string(),
args: serde_json::json!({ "slide_index": 0, "text": "Speaker notes" }),
},
temp_dir.path(),
)?;
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "append_notes".to_string(),
args: serde_json::json!({ "slide_index": 0, "text": "More context" }),
},
temp_dir.path(),
)?;
let layout_placeholders = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "list_layout_placeholders".to_string(),
args: serde_json::json!({ "layout_id": layout_id }),
},
temp_dir.path(),
)?;
assert_eq!(
layout_placeholders.placeholder_list.as_ref().map(Vec::len),
Some(2)
);
let slide_placeholders = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "list_slide_placeholders".to_string(),
args: serde_json::json!({ "slide_index": 0 }),
},
temp_dir.path(),
)?;
assert_eq!(
slide_placeholders.placeholder_list.as_ref().map(Vec::len),
Some(2)
);
let resolved_layout = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "resolve".to_string(),
args: serde_json::json!({ "id": format!("ly/{layout_id}") }),
},
temp_dir.path(),
)?;
assert_eq!(
resolved_layout
.resolved_record
.as_ref()
.and_then(|record| record.get("kind"))
.and_then(serde_json::Value::as_str),
Some("layout")
);
let inspect = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id),
action: "inspect".to_string(),
args: serde_json::json!({ "kind": "deck,slide,textbox,notes,layoutList" }),
},
temp_dir.path(),
)?;
let inspect_ndjson = inspect.inspect_ndjson.expect("inspect output");
assert!(inspect_ndjson.contains("\"kind\":\"layout\""));
assert!(inspect_ndjson.contains("\"kind\":\"notes\""));
assert!(inspect_ndjson.contains("\"placeholder\":\"title\""));
let truncated = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(created.artifact_id),
action: "inspect".to_string(),
args: serde_json::json!({
"kind": "deck,slide,textbox,notes,layoutList",
"max_chars": 250
}),
},
temp_dir.path(),
)?;
assert!(
truncated
.inspect_ndjson
.expect("truncated inspect")
.contains("\"kind\":\"notice\"")
);
Ok(())
}
#[test]
fn notes_visibility_controls_exported_notes() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let mut manager = PresentationArtifactManager::default();
let created = manager.execute(
PresentationArtifactRequest {
artifact_id: None,
action: "create".to_string(),
args: serde_json::json!({ "name": "Notes" }),
},
temp_dir.path(),
)?;
let artifact_id = created.artifact_id;
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "add_slide".to_string(),
args: serde_json::json!({ "notes": "Hidden notes" }),
},
temp_dir.path(),
)?;
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "set_notes_visibility".to_string(),
args: serde_json::json!({ "slide_index": 0, "visible": false }),
},
temp_dir.path(),
)?;
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id),
action: "export_pptx".to_string(),
args: serde_json::json!({ "path": "notes-hidden.pptx" }),
},
temp_dir.path(),
)?;
let imported = manager.execute(
PresentationArtifactRequest {
artifact_id: None,
action: "import_pptx".to_string(),
args: serde_json::json!({ "path": "notes-hidden.pptx" }),
},
temp_dir.path(),
)?;
let summary = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(imported.artifact_id),
action: "list_slides".to_string(),
args: serde_json::json!({}),
},
temp_dir.path(),
)?;
assert_eq!(
summary
.slide_list
.as_ref()
.and_then(|slides| slides.first())
.and_then(|slide| slide.notes.clone()),
None
);
Ok(())
}
#[test]
fn image_placeholders_and_anchor_updates_work() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let mut manager = PresentationArtifactManager::default();
let created = manager.execute(
PresentationArtifactRequest {
artifact_id: None,
action: "create".to_string(),
args: serde_json::json!({ "name": "Images" }),
},
temp_dir.path(),
)?;
let artifact_id = created.artifact_id;
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "add_slide".to_string(),
args: serde_json::json!({}),
},
temp_dir.path(),
)?;
let placeholder = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "add_image".to_string(),
args: serde_json::json!({
"slide_index": 0,
"position": { "left": 24, "top": 24, "width": 200, "height": 120 },
"fit": "contain",
"prompt": "Generate a hero illustration",
"alt": "Hero placeholder"
}),
},
temp_dir.path(),
)?;
let image_anchor = placeholder
.artifact_snapshot
.as_ref()
.and_then(|snapshot| snapshot.slides.first())
.and_then(|slide| slide.element_ids.first())
.map(|id| format!("im/{id}"))
.expect("image anchor");
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "update_shape_style".to_string(),
args: serde_json::json!({
"element_id": image_anchor,
"fit": "cover",
"crop": { "left": 0.1, "top": 0.0, "right": 0.1, "bottom": 0.0 },
"lock_aspect_ratio": true
}),
},
temp_dir.path(),
)?;
let resolved = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id),
action: "resolve".to_string(),
args: serde_json::json!({ "id": image_anchor }),
},
temp_dir.path(),
)?;
let record = resolved.resolved_record.expect("resolved image");
assert_eq!(
record.get("kind").and_then(serde_json::Value::as_str),
Some("image")
);
assert_eq!(
record
.get("isPlaceholder")
.and_then(serde_json::Value::as_bool),
Some(true)
);
assert_eq!(
record
.get("lockAspectRatio")
.and_then(serde_json::Value::as_bool),
Some(true)
);
Ok(())
}
#[test]
fn image_partial_resize_respects_lock_aspect_ratio() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let mut manager = PresentationArtifactManager::default();
let created = manager.execute(
PresentationArtifactRequest {
artifact_id: None,
action: "create".to_string(),
args: serde_json::json!({ "name": "Image Resize" }),
},
temp_dir.path(),
)?;
let artifact_id = created.artifact_id;
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "add_slide".to_string(),
args: serde_json::json!({}),
},
temp_dir.path(),
)?;
let added = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "add_image".to_string(),
args: serde_json::json!({
"slide_index": 0,
"position": { "left": 10, "top": 10, "width": 200, "height": 100 },
"prompt": "Placeholder image",
"lock_aspect_ratio": true
}),
},
temp_dir.path(),
)?;
let image_anchor = added
.artifact_snapshot
.as_ref()
.and_then(|snapshot| snapshot.slides.first())
.and_then(|slide| slide.element_ids.first())
.map(|id| format!("im/{id}"))
.expect("image anchor");
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "update_shape_style".to_string(),
args: serde_json::json!({
"element_id": image_anchor,
"position": { "width": 120 }
}),
},
temp_dir.path(),
)?;
let resolved = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id),
action: "resolve".to_string(),
args: serde_json::json!({ "id": image_anchor }),
},
temp_dir.path(),
)?;
let bbox = resolved
.resolved_record
.as_ref()
.and_then(|record| record.get("bbox"))
.and_then(serde_json::Value::as_array)
.expect("bbox");
assert_eq!(bbox[2].as_u64(), Some(120));
assert_eq!(bbox[3].as_u64(), Some(60));
Ok(())
}
#[test]
fn connectors_support_arrows_and_inspect() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let mut manager = PresentationArtifactManager::default();
let created = manager.execute(
PresentationArtifactRequest {
artifact_id: None,
action: "create".to_string(),
args: serde_json::json!({ "name": "Connectors" }),
},
temp_dir.path(),
)?;
let artifact_id = created.artifact_id;
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "add_slide".to_string(),
args: serde_json::json!({}),
},
temp_dir.path(),
)?;
let added = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "add_connector".to_string(),
args: serde_json::json!({
"slide_index": 0,
"connector_type": "elbow",
"start": { "left": 20, "top": 20 },
"end": { "left": 180, "top": 160 },
"line": { "color": "#ff0000", "width": 2, "style": "dash-dot" },
"start_arrow": "none",
"end_arrow": "triangle",
"arrow_size": "large",
"label": "flow"
}),
},
temp_dir.path(),
)?;
let connector_id = added
.artifact_snapshot
.as_ref()
.and_then(|snapshot| snapshot.slides.first())
.and_then(|slide| slide.element_ids.first())
.map(|id| format!("cn/{id}"))
.expect("connector id");
let resolved = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "resolve".to_string(),
args: serde_json::json!({ "id": connector_id }),
},
temp_dir.path(),
)?;
assert_eq!(
resolved
.resolved_record
.as_ref()
.and_then(|record| record.get("kind"))
.and_then(serde_json::Value::as_str),
Some("connector")
);
let inspect = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id),
action: "inspect".to_string(),
args: serde_json::json!({ "kind": "connector" }),
},
temp_dir.path(),
)?;
assert!(
inspect
.inspect_ndjson
.expect("connector inspect")
.contains("\"kind\":\"connector\"")
);
Ok(())
}
#[test]
fn z_order_helpers_resequence_elements() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let mut manager = PresentationArtifactManager::default();
let created = manager.execute(
PresentationArtifactRequest {
artifact_id: None,
action: "create".to_string(),
args: serde_json::json!({ "name": "Z Order" }),
},
temp_dir.path(),
)?;
let artifact_id = created.artifact_id;
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "add_slide".to_string(),
args: serde_json::json!({}),
},
temp_dir.path(),
)?;
let first = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "add_text_shape".to_string(),
args: serde_json::json!({
"slide_index": 0,
"text": "A",
"position": { "left": 10, "top": 10, "width": 100, "height": 40 }
}),
},
temp_dir.path(),
)?;
let second = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "add_text_shape".to_string(),
args: serde_json::json!({
"slide_index": 0,
"text": "B",
"position": { "left": 20, "top": 20, "width": 100, "height": 40 }
}),
},
temp_dir.path(),
)?;
let first_id = first
.artifact_snapshot
.as_ref()
.and_then(|snapshot| snapshot.slides.first())
.and_then(|slide| slide.element_ids.first())
.cloned()
.expect("first id");
let second_id = second
.artifact_snapshot
.as_ref()
.and_then(|snapshot| snapshot.slides.first())
.and_then(|slide| slide.element_ids.last())
.cloned()
.expect("second id");
let sent_back = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "send_to_back".to_string(),
args: serde_json::json!({ "element_id": format!("sh/{second_id}") }),
},
temp_dir.path(),
)?;
assert_eq!(
sent_back
.artifact_snapshot
.as_ref()
.and_then(|snapshot| snapshot.slides.first())
.map(|slide| slide.element_ids.clone()),
Some(vec![second_id.clone(), first_id.clone()])
);
let brought_front = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id),
action: "bring_to_front".to_string(),
args: serde_json::json!({ "element_id": format!("sh/{second_id}") }),
},
temp_dir.path(),
)?;
assert_eq!(
brought_front
.artifact_snapshot
.as_ref()
.and_then(|snapshot| snapshot.slides.first())
.map(|slide| slide.element_ids.clone()),
Some(vec![first_id, second_id])
);
Ok(())
}
#[test]
fn manager_supports_table_cell_updates_and_merges() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let mut manager = PresentationArtifactManager::default();
let created = manager.execute(
PresentationArtifactRequest {
artifact_id: None,
action: "create".to_string(),
args: serde_json::json!({ "name": "Tables" }),
},
temp_dir.path(),
)?;
let artifact_id = created.artifact_id;
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "add_slide".to_string(),
args: serde_json::json!({}),
},
temp_dir.path(),
)?;
let table = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "add_table".to_string(),
args: serde_json::json!({
"slide_index": 0,
"position": { "left": 24, "top": 24, "width": 240, "height": 120 },
"rows": [["A", "B"], ["C", "D"]],
"style": "TableStyleMedium9"
}),
},
temp_dir.path(),
)?;
let table_id = table
.artifact_snapshot
.as_ref()
.and_then(|snapshot| snapshot.slides.first())
.and_then(|slide| slide.element_ids.first())
.cloned()
.expect("table id");
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "update_table_cell".to_string(),
args: serde_json::json!({
"element_id": table_id,
"row": 0,
"column": 1,
"value": "Updated",
"background_fill": "#eeeeee",
"alignment": "right",
"styling": { "bold": true }
}),
},
temp_dir.path(),
)?;
let inspect = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "inspect".to_string(),
args: serde_json::json!({ "kind": "table" }),
},
temp_dir.path(),
)?;
assert!(
inspect
.inspect_ndjson
.expect("inspect")
.contains("\"kind\":\"table\"")
);
let merged = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id),
action: "merge_table_cells".to_string(),
args: serde_json::json!({
"element_id": table_id,
"start_row": 0,
"end_row": 0,
"start_column": 0,
"end_column": 1
}),
},
temp_dir.path(),
)?;
assert_eq!(
merged
.artifact_snapshot
.as_ref()
.map(|snapshot| snapshot.slide_count),
Some(1)
);
Ok(())
}
+1
View File
@@ -42,6 +42,7 @@ codex-hooks = { workspace = true }
codex-keyring-store = { workspace = true }
codex-network-proxy = { workspace = true }
codex-otel = { workspace = true }
codex-artifact-presentation = { workspace = true }
codex-protocol = { workspace = true }
codex-rmcp-client = { workspace = true }
codex-state = { workspace = true }
+6
View File
@@ -313,6 +313,9 @@
"apps_mcp_gateway": {
"type": "boolean"
},
"artifact": {
"type": "boolean"
},
"child_agents_md": {
"type": "boolean"
},
@@ -1700,6 +1703,9 @@
"apps_mcp_gateway": {
"type": "boolean"
},
"artifact": {
"type": "boolean"
},
"child_agents_md": {
"type": "boolean"
},
+12
View File
@@ -55,6 +55,9 @@ use async_channel::Receiver;
use async_channel::Sender;
use chrono::Local;
use chrono::Utc;
use codex_artifact_presentation::PresentationArtifactError;
use codex_artifact_presentation::PresentationArtifactRequest;
use codex_artifact_presentation::PresentationArtifactResponse;
use codex_hooks::HookEvent;
use codex_hooks::HookEventAfterAgent;
use codex_hooks::HookPayload;
@@ -1777,6 +1780,15 @@ impl Session {
state.clear_connector_selection();
}
pub(crate) async fn execute_presentation_artifact(
&self,
request: PresentationArtifactRequest,
cwd: &Path,
) -> Result<PresentationArtifactResponse, PresentationArtifactError> {
let mut state = self.state.lock().await;
state.presentation_artifacts.execute(request, cwd)
}
async fn record_initial_history(&self, conversation_history: InitialHistory) {
let turn_context = self.new_default_turn().await;
self.clear_mcp_tool_selection().await;
+8
View File
@@ -145,6 +145,8 @@ pub enum Feature {
CollaborationModes,
/// Enable personality selection in the TUI.
Personality,
/// Enable native artifact tools.
Artifact,
/// Enable Fast mode selection in the TUI and request layer.
FastMode,
/// Enable voice transcription in the TUI composer.
@@ -662,6 +664,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::Artifact,
key: "artifact",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::FastMode,
key: "fast_mode",
+3
View File
@@ -1,5 +1,6 @@
//! Session-wide mutable state.
use codex_artifact_presentation::PresentationArtifactManager;
use codex_protocol::models::ResponseItem;
use std::collections::HashMap;
use std::collections::HashSet;
@@ -32,6 +33,7 @@ pub(crate) struct SessionState {
pub(crate) startup_regular_task: Option<JoinHandle<CodexResult<RegularTask>>>,
pub(crate) active_mcp_tool_selection: Option<Vec<String>>,
pub(crate) active_connector_selection: HashSet<String>,
pub(crate) presentation_artifacts: PresentationArtifactManager,
}
impl SessionState {
@@ -49,6 +51,7 @@ impl SessionState {
startup_regular_task: None,
active_mcp_tool_selection: None,
active_connector_selection: HashSet::new(),
presentation_artifacts: PresentationArtifactManager::default(),
}
}
+2
View File
@@ -8,6 +8,7 @@ mod mcp;
mod mcp_resource;
pub(crate) mod multi_agents;
mod plan;
mod presentation_artifact;
mod read_file;
mod request_user_input;
mod search_tool_bm25;
@@ -38,6 +39,7 @@ pub use mcp::McpHandler;
pub use mcp_resource::McpResourceHandler;
pub use multi_agents::MultiAgentHandler;
pub use plan::PlanHandler;
pub use presentation_artifact::PresentationArtifactHandler;
pub use read_file::ReadFileHandler;
pub use request_user_input::RequestUserInputHandler;
pub(crate) use request_user_input::request_user_input_tool_description;
@@ -0,0 +1,254 @@
use async_trait::async_trait;
use codex_artifact_presentation::PathAccessKind;
use codex_artifact_presentation::PathAccessRequirement;
use codex_artifact_presentation::PresentationArtifactError;
use codex_artifact_presentation::PresentationArtifactRequest;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ReviewDecision;
use serde_json::to_string;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::features::Feature;
use crate::function_tool::FunctionCallError;
use crate::path_utils::normalize_for_path_comparison;
use crate::path_utils::resolve_symlink_write_paths;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use crate::tools::sandboxing::with_cached_approval;
use codex_protocol::models::FunctionCallOutputBody;
pub struct PresentationArtifactHandler;
#[async_trait]
impl ToolHandler for PresentationArtifactHandler {
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn is_mutating(&self, invocation: &ToolInvocation) -> bool {
let ToolPayload::Function { arguments } = &invocation.payload else {
return true;
};
let Ok(request) = parse_arguments::<PresentationArtifactRequest>(arguments) else {
return true;
};
!matches!(
request.action.as_str(),
"get_summary"
| "list_slides"
| "list_layouts"
| "list_layout_placeholders"
| "list_slide_placeholders"
| "inspect"
| "resolve"
)
}
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
let ToolInvocation {
session,
turn,
payload,
call_id,
..
} = invocation;
if !session.enabled(Feature::Artifact) {
return Err(FunctionCallError::RespondToModel(
"presentation_artifact is disabled by feature flag".to_string(),
));
}
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(
"presentation_artifact handler received unsupported payload".to_string(),
));
}
};
let request: PresentationArtifactRequest = parse_arguments(&arguments)?;
for access in request
.required_path_accesses(&turn.cwd)
.map_err(presentation_error)?
{
authorize_path_access(session.as_ref(), turn.as_ref(), &call_id, &access).await?;
}
let response = session
.execute_presentation_artifact(request, &turn.cwd)
.await
.map_err(presentation_error)?;
Ok(ToolOutput::Function {
body: FunctionCallOutputBody::Text(to_string(&response).map_err(|error| {
FunctionCallError::RespondToModel(format!(
"failed to serialize presentation_artifact response: {error}"
))
})?),
success: Some(true),
})
}
}
fn presentation_error(error: PresentationArtifactError) -> FunctionCallError {
FunctionCallError::RespondToModel(error.to_string())
}
async fn authorize_path_access(
session: &Session,
turn: &TurnContext,
call_id: &str,
access: &PathAccessRequirement,
) -> Result<(), FunctionCallError> {
let effective_path = match access.kind {
PathAccessKind::Read => effective_read_path(&access.path),
PathAccessKind::Write => effective_write_path(&access.path),
};
let allowed = match access.kind {
PathAccessKind::Read => path_is_readable(turn, &effective_path),
PathAccessKind::Write => path_is_writable(turn, &effective_path),
};
if allowed {
return Ok(());
}
let approval_policy = turn.approval_policy.value();
if !matches!(
approval_policy,
AskForApproval::OnRequest | AskForApproval::UnlessTrusted
) {
return Err(FunctionCallError::RespondToModel(format!(
"{} path `{}` is outside the current sandbox policy",
access_kind_label(access.kind),
access.path.display()
)));
}
let key = format!(
"presentation_artifact:{:?}:{}",
access.kind,
effective_path.display()
);
let path = access.path.clone();
let action = access.action.clone();
let decision = with_cached_approval(
&session.services,
"presentation_artifact",
vec![key],
|| {
let path = path.clone();
let action = action.clone();
async move {
session
.request_command_approval(
turn,
call_id.to_string(),
None,
vec![
"presentation_artifact".to_string(),
action,
path.display().to_string(),
],
turn.cwd.clone(),
Some(format!(
"Allow presentation_artifact to {} `{}`?",
access_kind_verb(access.kind),
path.display()
)),
None,
None,
None,
None,
)
.await
}
},
)
.await;
if matches!(
decision,
ReviewDecision::Approved
| ReviewDecision::ApprovedForSession
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
) {
return Ok(());
}
Err(FunctionCallError::RespondToModel(format!(
"{} path `{}` was not approved",
access_kind_label(access.kind),
access.path.display()
)))
}
fn path_is_readable(turn: &TurnContext, path: &Path) -> bool {
if turn.sandbox_policy.has_full_disk_read_access() {
return true;
}
turn.sandbox_policy
.get_readable_roots_with_cwd(&turn.cwd)
.iter()
.any(|root| path.starts_with(root.as_path()))
}
fn path_is_writable(turn: &TurnContext, path: &Path) -> bool {
if turn.sandbox_policy.has_full_disk_write_access() {
return true;
}
turn.sandbox_policy
.get_writable_roots_with_cwd(&turn.cwd)
.iter()
.any(|root| root.is_path_writable(path))
}
fn effective_read_path(path: &Path) -> PathBuf {
normalize_for_path_comparison(path).unwrap_or_else(|_| normalize_without_fs(path))
}
fn effective_write_path(path: &Path) -> PathBuf {
let write_path = resolve_symlink_write_paths(path)
.map(|paths| paths.write_path)
.unwrap_or_else(|_| path.to_path_buf());
normalize_for_path_comparison(&write_path).unwrap_or_else(|_| normalize_without_fs(&write_path))
}
fn normalize_without_fs(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::ParentDir => {
normalized.pop();
}
Component::CurDir => {}
other => normalized.push(other.as_os_str()),
}
}
normalized
}
fn access_kind_label(kind: PathAccessKind) -> &'static str {
match kind {
PathAccessKind::Read => "read",
PathAccessKind::Write => "write",
}
}
fn access_kind_verb(kind: PathAccessKind) -> &'static str {
match kind {
PathAccessKind::Read => "read from",
PathAccessKind::Write => "write to",
}
}
+67
View File
@@ -35,6 +35,8 @@ use std::collections::HashMap;
const SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE: &str =
include_str!("../../templates/search_tool/tool_description.md");
const PRESENTATION_ARTIFACT_DESCRIPTION_TEMPLATE: &str =
include_str!("../../templates/tools/presentation_artifact.md");
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum ShellCommandBackendConfig {
@@ -55,6 +57,7 @@ pub(crate) struct ToolsConfig {
pub js_repl_enabled: bool,
pub js_repl_tools_only: bool,
pub collab_tools: bool,
pub presentation_artifact: bool,
pub default_mode_request_user_input: bool,
pub experimental_supported_tools: Vec<String>,
pub agent_jobs_tools: bool,
@@ -84,6 +87,7 @@ impl ToolsConfig {
let include_default_mode_request_user_input =
features.enabled(Feature::DefaultModeRequestUserInput);
let include_search_tool = features.enabled(Feature::Apps);
let include_presentation_artifact = features.enabled(Feature::Artifact);
let include_agent_jobs = include_collab_tools && features.enabled(Feature::Sqlite);
let request_permission_enabled = features.enabled(Feature::RequestPermissions);
let shell_command_backend =
@@ -139,6 +143,7 @@ impl ToolsConfig {
js_repl_enabled: include_js_repl,
js_repl_tools_only: include_js_repl_tools_only,
collab_tools: include_collab_tools,
presentation_artifact: include_presentation_artifact,
default_mode_request_user_input: include_default_mode_request_user_input,
experimental_supported_tools: model_info.experimental_supported_tools.clone(),
agent_jobs_tools: include_agent_jobs,
@@ -561,6 +566,44 @@ fn create_view_image_tool() -> ToolSpec {
})
}
fn create_presentation_artifact_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"artifact_id".to_string(),
JsonSchema::String {
description: Some(
"Artifact id returned by an earlier presentation_artifact call.".to_string(),
),
},
),
(
"action".to_string(),
JsonSchema::String {
description: Some("Action name to run against the artifact.".to_string()),
},
),
(
"args".to_string(),
JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: Some(true.into()),
},
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "presentation_artifact".to_string(),
description: PRESENTATION_ARTIFACT_DESCRIPTION_TEMPLATE.to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["action".to_string(), "args".to_string()]),
additional_properties: Some(false.into()),
},
})
}
fn create_collab_input_items_schema() -> JsonSchema {
let properties = BTreeMap::from([
(
@@ -1658,6 +1701,7 @@ pub(crate) fn build_specs(
use crate::tools::handlers::McpResourceHandler;
use crate::tools::handlers::MultiAgentHandler;
use crate::tools::handlers::PlanHandler;
use crate::tools::handlers::PresentationArtifactHandler;
use crate::tools::handlers::ReadFileHandler;
use crate::tools::handlers::RequestUserInputHandler;
use crate::tools::handlers::SearchToolBm25Handler;
@@ -1685,6 +1729,7 @@ pub(crate) fn build_specs(
let search_tool_handler = Arc::new(SearchToolBm25Handler);
let js_repl_handler = Arc::new(JsReplHandler);
let js_repl_reset_handler = Arc::new(JsReplResetHandler);
let presentation_artifact_handler = Arc::new(PresentationArtifactHandler);
let request_permission_enabled = config.request_permission_enabled;
match &config.shell_type {
@@ -1822,6 +1867,11 @@ pub(crate) fn build_specs(
builder.push_spec_with_parallel_support(create_view_image_tool(), true);
builder.register_handler("view_image", view_image_handler);
if config.presentation_artifact {
builder.push_spec(create_presentation_artifact_tool());
builder.register_handler("presentation_artifact", presentation_artifact_handler);
}
if config.collab_tools {
let multi_agent_handler = Arc::new(MultiAgentHandler);
builder.push_spec(create_spawn_agent_tool(config));
@@ -2122,6 +2172,23 @@ mod tests {
);
}
#[test]
fn test_build_specs_artifact_tool_enabled() {
let config = test_config();
let model_info =
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::Artifact);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
assert_contains_tool_names(&tools, &["presentation_artifact"]);
}
#[test]
fn test_build_specs_agent_job_worker_tools_enabled() {
let config = test_config();
@@ -0,0 +1,95 @@
Create and edit PowerPoint presentation artifacts inside the current thread.
- This is a stateful built-in tool. `artifact_id` values are returned by earlier calls and persist only for the current thread.
- Resume and fork do not restore live artifact state. Export files if you need a durable handoff.
- Relative paths resolve from the current working directory.
- Position and size values are in slide points.
Supported actions:
- `create`
- `import_pptx`
- `export_pptx`
- `export_preview`
- `get_summary`
- `list_slides`
- `list_layouts`
- `list_layout_placeholders`
- `list_slide_placeholders`
- `inspect`
- `resolve`
- `create_layout`
- `add_layout_placeholder`
- `set_slide_layout`
- `update_placeholder_text`
- `set_theme`
- `set_notes`
- `append_notes`
- `clear_notes`
- `set_notes_visibility`
- `add_slide`
- `insert_slide`
- `duplicate_slide`
- `move_slide`
- `delete_slide`
- `set_slide_background`
- `add_text_shape`
- `add_shape`
- `add_connector`
- `add_image`
- `replace_image`
- `add_table`
- `update_table_cell`
- `merge_table_cells`
- `add_chart`
- `update_text`
- `update_shape_style`
- `bring_to_front`
- `send_to_back`
- `delete_element`
- `delete_artifact`
Example create:
`{"action":"create","args":{"name":"Quarterly Update"}}`
Example edit:
`{"artifact_id":"presentation_x","action":"add_text_shape","args":{"slide_index":0,"text":"Revenue up 24%","position":{"left":48,"top":72,"width":260,"height":80}}}`
Example export:
`{"artifact_id":"presentation_x","action":"export_pptx","args":{"path":"artifacts/q2-update.pptx"}}`
Example layout flow:
`{"artifact_id":"presentation_x","action":"create_layout","args":{"name":"Title Slide"}}`
`{"artifact_id":"presentation_x","action":"add_layout_placeholder","args":{"layout_id":"layout_1","name":"title","placeholder_type":"title","text":"Click to add title","position":{"left":48,"top":48,"width":624,"height":72}}}`
`{"artifact_id":"presentation_x","action":"set_slide_layout","args":{"slide_index":0,"layout_id":"layout_1"}}`
`{"artifact_id":"presentation_x","action":"list_layout_placeholders","args":{"layout_id":"layout_1"}}`
`{"artifact_id":"presentation_x","action":"list_slide_placeholders","args":{"slide_index":0}}`
Example inspect:
`{"artifact_id":"presentation_x","action":"inspect","args":{"kind":"deck,slide,textbox,shape,table,chart,image,notes,layoutList","max_chars":12000}}`
Example resolve:
`{"artifact_id":"presentation_x","action":"resolve","args":{"id":"sh/element_3"}}`
Notes visibility is honored on export: `set_notes_visibility` controls whether speaker notes are emitted into exported PPTX output.
Image placeholders can be prompt-only. `add_image` accepts `prompt` without `path`/`data_url`, and unresolved placeholders export as a visible placeholder box instead of failing.
Remote images are supported. `add_image` and `replace_image` accept `uri` in addition to local `path` and `data_url`.
Image edits can target inspect/resolve anchors like `im/element_3`, and `update_shape_style` now accepts image `fit`, `crop`, and `lock_aspect_ratio` updates.
`update_shape_style.position` accepts partial updates, so you can move or resize an element without resending the full rect.
Connectors are supported via `add_connector`, with straight/elbow/curved types plus dash styles and arrow heads.
Example preview:
`{"artifact_id":"presentation_x","action":"export_preview","args":{"slide_index":0,"path":"artifacts/q2-update-slide1.png"}}`
`export_preview` also accepts `format`, `scale`, and `quality` for rendered previews. `format` currently supports `png` and `jpeg`.
Example JPEG preview:
`{"artifact_id":"presentation_x","action":"export_preview","args":{"slide_index":0,"path":"artifacts/q2-update-slide1.jpg","format":"jpeg","scale":0.75,"quality":85}}`
+1
View File
@@ -78,6 +78,7 @@ ignore = [
# TODO(fcoury): remove this exception when syntect drops yaml-rust and bincode, or updates to versions that have fixed the vulnerabilities.
{ id = "RUSTSEC-2024-0320", reason = "yaml-rust is unmaintained; pulled in via syntect v5.3.0 used by codex-tui for syntax highlighting; no fixed release yet" },
{ id = "RUSTSEC-2025-0141", reason = "bincode is unmaintained; pulled in via syntect v5.3.0 used by codex-tui for syntax highlighting; no fixed release yet" },
{ id = "RUSTSEC-2025-0134", reason = "rustls-pemfile is unmaintained; pulled in via reqwest -> ppt-rs -> codex-artifact-presentation; no safe upgrade available yet" },
]
# If this is true, then cargo deny will use the git executable to fetch advisory database.
# If this is false, then it uses a built-in git library.