mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat: presentation artifact p1 (#13341)
Part 1 of presentation tool artifact
This commit is contained in:
committed by
GitHub
Unverified
parent
07e532dcb9
commit
4874b9291a
Generated
+41
File diff suppressed because one or more lines are too long
Generated
+570
-61
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
)
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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}}`
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user