Canvas AI: Add the place_components AI tool (+ kernel test)
## Summary Add a new AI tool `canvas_ai:place_components` (class `PlaceComponents`) to `canvas_ai`, ported from the POC (MR !1214). It places components into the current layout at a given region/slot target and returns the assigned UUIDs plus the predicted layout tree. It lands **inert** — no agent lists it yet — so it changes nothing until a later issue wires it to the dev agent. Add a kernel test adapted from the existing `set_component_structure` test. ## Why (this sprint) `place_components` is the core mutation tool of the client-side hop loop: the agent places one section per turn and references the returned UUID when placing the next. It is deterministic PHP — fully testable on its own, with the agent only *selecting* it — so it can land now, tested, in parallel with the module/wizard work. It is a **new** tool rather than an edit to `set_component_structure` because the live `canvas_page_builder_agent` already lists `canvas_ai:set_component_structure` in its tools; changing that tool's logic would alter behavior for the existing agent. The old tool is removed at graduation once the new agents replace it. - Roadmap: https://git.drupalcode.org/project/canvas/-/work_items/3591777 - Reference implementation (POC, no test): https://git.drupalcode.org/project/canvas/-/merge_requests/1214 ## What to build Port `PlaceComponents` from the POC to `canvas_ai` under the final ID `canvas_ai:place_components`. It mirrors the existing `SetAIGeneratedComponentStructure` (`canvas_ai:set_component_structure`) tool: same operations YAML input, same validation, same shared-layout services. Port the POC class largely as-is (apart from any small optimizations) rather than re-deriving it. - **Argument.** A single argument holding the operations YAML string. Name it **`component_structure_yaml`** (the POC calls it `component_structure`; use the `_yaml` name here to make the format explicit). - **Interfaces.** `final class PlaceComponents extends FunctionCallBase implements ExecutableFunctionCallInterface, AiAgentContextInterface, BuilderResponseFunctionCallInterface` — the `BuilderResponseFunctionCallInterface` marker is what makes `getStructuredOutput()` merge into the hop response. - **Validation.** Calls `AiResponseValidator::validateComponentStructure()` (`canvas_ai.response_validator`) on each operation's `components`, exactly as `set_component_structure` does. - **Layout mapping / shared state.** Calls `CanvasAiPageBuilderHelper::customYamlToArrayMapper()` (`canvas_ai.page_builder_helper`), which reads the running layout from `CanvasAiTempStore::CURRENT_LAYOUT_KEY` (`canvas_ai.tempstore`), merges the placement in, computes each component's `nodePath`, and predicts the resulting tree. This tempstore-backed layout is what lets successive `place_components` calls build on each other. - **Output.** `getStructuredOutput()` returns the `{ operations: [...] }` payload plus a message built from `CanvasAiPlacementDto` (`canvas_ai.placement_dto`): `getComponentStructureWithUuids()` (the placed components with their assigned UUIDs) and `getPredictedLayout()` (the predicted UUID tree), with the POC's "continuation point" wording. The frontend applies this to the Redux layout. ### Services `canvas_ai.response_validator`, `canvas_ai.page_builder_helper`, and `canvas_ai.tempstore` already exist in `canvas_ai`. **`canvas_ai.placement_dto` (`CanvasAiPlacementDto`) does not — create it**: port the class from the POC and register it in `canvas_ai.services.yml`. If the additive `CanvasAiPageBuilderHelper` methods this tool relies on (`customYamlToArrayMapper()` and the DTO wiring) are not yet present in live, port them from the POC alongside — they are additive. ### Inert on landing Do **not** add `place_components` to any agent's tool list in this issue. It ships as an available plugin only; wiring it to the dev agent is a later issue. This keeps the change additive and invisible to the live editor. ## Tests New kernel test `modules/canvas_ai/tests/src/Kernel/Plugin/AiFunctionCall/PlaceComponentsTest.php` extending `CanvasKernelTestBase`, adapted from `SetAIGeneratedComponentStructureTest` (same fixtures, `getCurrentLayout()` helper, and `normalizeErrorString()`). Cover: - **Happy path.** Valid `operations` → assert `getStructuredOutput()` equals the expected payload, including the predicted-layout message. - **Permission.** Without the permission, running the tool throws. - **Invalid YAML** (data provider). - **Component validation errors**, top-level and nested: non-existent component id, missing required prop, invalid slot name, non-existent prop — copied from the existing test. - **Placement/region targeting** (place_components-specific, not in the structure test): unknown `target` region, unknown `reference_uuid`, and each `placement` value (`inside` / `above` / `below`) resolving to the correct `nodePath`. Assert the exact error strings, as the existing test does. ## Explicitly NOT doing - Not modifying or deleting `set_component_structure` (removed at graduation). - Not wiring `place_components` to any agent. - Not building `edit_components` / `set_page_value` / `get_component_details` — separate issues sharing the same validator/helper. --- _Issue generated with AI assistance._
issue