MCP: the trace-and-style prompt's steps run as written

When an agent followed the trace-and-style prompt step by step, it
failed at the second step: open_file leaves the image unselected, so
imageTrace.make returned "nothing selected". The text did not say that
the Default preset traces in black and white. No step expanded the
trace, so the source image stayed in the document. The set_paint step
gave every traced shape the same paint. Step 2 also said that
screenshots show the Image Trace view, while render_preview and
screenshot without window draw the tracing result. Step 1 said that
the image opens at its own pixel size, while an image that declares a
resolution opens at its size at that resolution.

The steps now select the image with select.all (or name its id), say
which presets keep colors, expand the trace with imageTrace.expand or
imageTrace.makeAndExpand, recolor with recolor.colors and recolor.apply,
and check the result with render_preview or screenshot. The prompt's
name, arguments and defaults are unchanged.
This commit is contained in:
dotCooCoo
2026-10-24 17:46:15 +07:00
committed by bflatastic
parent a7ef6ce78c
commit 4ca0c26f2f
2 changed files with 91 additions and 7 deletions
+37 -7
View File
@@ -270,7 +270,7 @@ One undo step covers the whole change; gradients and linked swatches stay linked
PromptDef {
name: "trace-and-style",
title: "Trace an image and style it",
description: "Trace a placed raster with Image Trace, then apply a live effect and paint over the result.",
description: "Trace an image with Image Trace and expand the trace, then apply a live effect and recolor the result.",
arguments: &[
PromptArg { name: "path", description: "Path to the image file", required: true, default: "", values: Values::Fixed(NONE) },
PromptArg {
@@ -293,8 +293,13 @@ Trace the image at {path} in VectorCraft with the \"{preset}\" preset, then styl
with the {effect} effect.
Method:
1. open_file the image: it becomes a document of its own pixel size.
2. imageTrace.make with the preset name, adjusting `params` — mode blackAndWhite, grayscale,
1. open_file the image: it becomes a document of the image's size at the resolution it declares
(1 px = 1 pt at 72 ppi or when it declares none), with nothing selected.
Then run_command select.all to select the image, or pass the image's `id` (inspect_document
lists it) to imageTrace.make.
2. imageTrace.make with the preset name. The Default preset traces in black and white; a color
preset such as 6 Colors or 16 Colors, or `params` with mode color, keeps the image's colors.
Adjust `params`: mode blackAndWhite, grayscale,
color (palette limited, fullTone for photos, automatic, or documentLibrary to trace with
the document's swatches or a swatch `library`) or logo (flat-colour logos only; the Flat Logo
preset; colours via logoColors), then threshold, colors (colorDetail for
@@ -304,11 +309,16 @@ Method:
imageTrace.presets to see every preset with its numbers.
Lower paths and higher corners give the clean, logo-like result; high fidelity needs paths
near 90. imageTrace.setView {view: \"outlinesWithSourceImage\"} draws the traced outlines
over the image on screen to compare them (screenshots show it; exports keep the result).
3. apply_effect {effect} on the traced shapes. Call apply_effect with no `effect` for the
over the image in the app's window to compare them (screenshot {window: true} captures it);
render_preview and screenshot without `window` draw the tracing result, as exports do.
3. imageTrace.expand keeps only the traced paths and removes the source image.
imageTrace.makeAndExpand traces and expands in one command, in place of steps 2 and 3.
4. apply_effect {effect} on the traced shapes. Call apply_effect with no `effect` for the
catalogue and each effect's parameters.
4. set_paint over the traced shapes to give the artwork its own colours.
5. screenshot, then remove the placed image if it is still underneath.
5. To change the colors, run recolor.colors on the traced shapes, then recolor.apply with a
`map` from each traced color to its new color. set_paint gives every selected shape the
same fill or stroke.
6. render_preview or screenshot, and compare the result with the source image.
The trace is real geometry: every traced shape stays selectable and editable.",
},
@@ -546,6 +556,26 @@ mod tests {
assert!(e.contains("brief"), "{e}");
}
/// trace-and-style selects the opened image before tracing it, expands the trace, recolors it
/// instead of painting it one color, and says that screenshots draw the tracing result.
#[test]
fn trace_and_style_selects_expands_and_recolors() {
let v = get("trace-and-style", &json!({"path": "art.png"})).unwrap();
let text = v["messages"][0]["content"]["text"].as_str().unwrap_or_default();
for step in ["open_file", "select.all", "imageTrace.make", "imageTrace.expand", "apply_effect", "recolor.apply", "render_preview"] {
assert!(text.contains(step), "{step}: {text}");
}
let at = |s: &str| text.find(s).unwrap_or(usize::MAX);
assert!(at("select.all") < at("imageTrace.make") && at("imageTrace.make") < at("imageTrace.expand"), "{text}");
assert!(text.contains("black and white"), "{text}");
assert!(!text.contains("screenshots show it"), "{text}");
assert!(!text.contains("own pixel size") && text.contains("resolution it declares"), "{text}");
// No step of its own paints the trace with set_paint.
let steps: Vec<&str> = text.lines().filter(|l| is_item(l)).filter_map(|l| l.split_once(". ").map(|(_, rest)| rest)).collect();
assert!(!steps.is_empty(), "{text}");
assert!(steps.iter().all(|s| !s.starts_with("set_paint")), "{steps:?}");
}
#[test]
fn completions_rank_prefix_first_and_cap_at_100() {
let mut b = b();
+54
View File
@@ -296,6 +296,60 @@ fn headless_end_to_end() {
assert_eq!(v["error"]["code"], -32002);
}
/// The trace-and-style prompt's steps, run in its order on an image drawn here: each one
/// succeeds, the expanded trace holds no image, and its paths keep the image's colors.
#[test]
fn trace_and_style_prompt_steps_run_in_order() {
let mut s = server();
// 60 × 40 pixels: a red and a blue block on white.
let png = tmp("trace-and-style.png");
image::RgbImage::from_fn(60, 40, |x, y| match (x, y) {
(5..25, 5..35) => image::Rgb([220, 30, 30]),
(35..55, 5..35) => image::Rgb([30, 60, 200]),
_ => image::Rgb([255, 255, 255]),
})
.save(&png)
.unwrap();
let path = png.to_str().unwrap();
let v = rpc(&mut s, 1, "prompts/get", json!({"name": "trace-and-style", "arguments": {"path": path}}));
let prompt = v["result"]["messages"][0]["content"]["text"].as_str().unwrap().to_string();
let ok = |s: &mut Server, id: u64, name: &str, args: Value| {
let r = call(s, id, name, args);
assert_eq!(r["isError"], false, "{name}: {r}");
serde_json::from_str::<Value>(&text_of(&r)).unwrap_or(Value::Null)
};
let command = |s: &mut Server, id: u64, command: &str, params: Value| {
assert!(prompt.contains(command), "the prompt names {command}: {prompt}");
ok(s, id, "run_command", json!({"command": command, "params": params}))
};
ok(&mut s, 2, "open_file", json!({"path": path}));
command(&mut s, 3, "select.all", json!({}));
let traced = command(&mut s, 4, "imageTrace.make", json!({"preset": "6 Colors"}));
assert!(traced["colors"].as_u64().unwrap() > 1, "{traced}");
command(&mut s, 5, "imageTrace.expand", json!({}));
ok(&mut s, 6, "apply_effect", json!({"effect": "stylize.scribble"}));
let colors = command(&mut s, 7, "recolor.colors", json!({}));
let from = colors["colors"].as_array().unwrap().iter().filter_map(|c| c["hex"].as_str()).find(|h| *h != "#ffffff").unwrap().to_string();
command(&mut s, 8, "recolor.apply", json!({"map": {from.as_str(): "#00aa00"}}));
let r = call(&mut s, 9, "render_preview", json!({}));
assert_eq!(r["isError"], false, "{r}");
// No image is left, and the paths carry more than one fill color, the new one among them.
let doc = ok(&mut s, 10, "inspect_document", json!({}));
let (mut kinds, mut fills) = (vec![], std::collections::BTreeSet::new());
let mut stack: Vec<&Value> = doc["layers"].as_array().unwrap().iter().collect();
while let Some(n) = stack.pop() {
kinds.push(n["kind"].as_str().unwrap_or_default().to_string());
if let Some(fill) = n["fill"].as_str().filter(|f| f.starts_with('#')) {
fills.insert(fill.to_string());
}
stack.extend(n["children"].as_array().into_iter().flatten());
}
assert!(!kinds.iter().any(|k| k == "Image"), "{kinds:?}");
assert!(fills.len() > 1 && fills.contains("#00aa00") && !fills.contains(&from), "{fills:?}");
}
#[test]
fn headless_path_gesture_undo() {
let mut s = server();