diff --git a/crates/engine/src/batch_step_ref_tests.rs b/crates/engine/src/batch_step_ref_tests.rs index 7474d546..ac69118e 100644 --- a/crates/engine/src/batch_step_ref_tests.rs +++ b/crates/engine/src/batch_step_ref_tests.rs @@ -89,3 +89,24 @@ fn text_that_looks_like_a_reference_is_left_alone() { let n = s.doc().unwrap().node(id(&results[0])).unwrap().clone(); assert!(matches!(n.kind, figcraft_doc::NodeKind::Text(t) if t.characters == "$step 0 {\"$step\": 0}")); } + +#[test] +fn a_nested_batch_refers_to_its_own_steps() { + let mut s = Session::new(); + let frame = |name: &str| json!({"command": "node.create", "params": {"type": "frame", "name": name, "width": 100, "height": 100}}); + let results = s + .execute( + "command.batch", + &json!({"commands": [ + frame("Outer"), + {"command": "command.batch", "params": {"commands": [ + frame("Inner"), + {"command": "node.create", "params": {"type": "rectangle", "parent": {"$step": 0}, "width": 10, "height": 10}} + ]}} + ]}), + ) + .unwrap(); + let inner = &results[1]; + let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone(); + assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own first frame, not the outer one"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index eee9e17a..1585aa38 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1124,12 +1124,14 @@ impl Session { /// Run several commands as one undo step. A param value `{"$step": n}` is replaced by the `id` /// in the result of the earlier step `n` (`{"$step": n, "field": "…"}` picks another field), - /// so a step can target a layer that an earlier step of the same batch created. + /// so a step can target a layer that an earlier step of the same batch created. Any object + /// with a `$step` key in a step's params is such a reference, except inside a nested + /// `command.batch` step: its references are to its own steps. pub fn batch(&mut self, label: &str, cmds: &[(String, Value)]) -> Result> { self.grouped(label, |s| { let mut results: Vec = Vec::with_capacity(cmds.len()); for (step, (id, p)) in cmds.iter().enumerate() { - let p = resolve_step_refs(p, &results, step, 0)?; + let p = if id == "command.batch" { p.clone() } else { resolve_step_refs(p, &results, step, 0)? }; results.push(s.execute(id, &p)?); } Ok(results) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3e0cf9ba..134a6637 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -63,8 +63,9 @@ JSON or the gradient shorthand `{"type":"linearGradient","stops":["#hex","#hex"] ``` Inside `command.batch`, `{"$step": n}` in a step's params stands for the `id` in step `n`'s result -(zero-based; `{"$step": n, "field": "…"}` for another field), so one batch can create a frame and -fill it: +(zero-based; `{"$step": n, "field": "…"}` for another field; any object with a `$step` key is read +as one, and a nested `command.batch` step keeps its own references to its own steps), so one batch +can create a frame and fill it: ```json {"id":4,"method":"engine.execute","params":{"command":"command.batch","params":{"commands":[{"command":"node.create","params":{"type":"frame","width":200,"height":100}},{"command":"node.create","params":{"type":"rectangle","parent":{"$step":0},"width":20,"height":20}}]}}} diff --git a/docs/mcp.md b/docs/mcp.md index 862ade2d..07d84e60 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1532,7 +1532,10 @@ result. This builds a nested tree in one call and one undo step, without guessin A reference to the same or a later step, a negative or non-integer index, a missing `field` or any other key next to `$step` fails the whole batch, which is rolled back. Only an object whose keys are -`$step` (and optionally `field`) is a reference: text such as `"$step 0"` is left as written. +`$step` (and optionally `field`) is a reference: text such as `"$step 0"` is left as written. The +`$step` key is reserved in step params: an object holding it anywhere (in plugin data or a variable +value too) is read as a reference. A step that is itself a `command.batch` is passed on as written, +so the references in its steps are to its own steps, not the outer batch's. The end-to-end tests in `crates/mcp/tests/agent_tasks.rs` are worked examples of realistic tasks (auto layout, a card built in one batch with step references, components and overrides, gradients