This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch feature/CAMEL-25501-edit-file-edits in repository https://gitbox.apache.org/repos/asf/camel.git
commit f4611486dc1ca04c4813c3d6a36c433de89a7ee0 Author: Claus Ibsen <[email protected]> AuthorDate: Fri Oct 9 23:08:51 2026 +0200 CAMEL-25501: camel-jbang-mcp - camel_edit_file can apply several edits to one file in one call camel_edit_file replaced one snippet per call, so a change to two places of a file (send to direct:shipment, and add the route that consumes it) was written in two calls, and dev mode reloaded the half-done file in between: exchanges waited for the route not there yet and were cut off by the next reload. - edits: an optional list of find/replace, applied in order to the content, validated, written and reloaded once; all or nothing, the answer says which edit missed - a write or edit whose YAML route sends to a direct: endpoint no route consumes yet answers with a note, so the route goes in the same call - array parameters with an items schema in the shared tool descriptor; camel ask passes list arguments as JSON - the camel-jbang monitor applies the edits as one confirmed change, notes an unconsumed direct: endpoint, and its AI panel prompt points to camel_edit_file (full prefix budget raised to 10500 for the edits schema) Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m --- .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 3 + .../modules/ROOT/pages/camel-jbang-mcp.adoc | 5 + .../ROOT/pages/camel-jbang-tui-ai-agents.adoc | 2 +- .../camel/dsl/jbang/core/commands/AskTools.java | 12 +- .../dsl/jbang/core/commands/ai/AuthoringTools.java | 174 +++++++++++++++++++-- .../dsl/jbang/core/commands/ai/ToolDescriptor.java | 34 +++- .../core/commands/ai/AuthoringToolsEditTest.java | 112 +++++++++++++ .../jbang/core/commands/mcp/AuthoringTools.java | 38 ++++- .../core/commands/mcp/AuthoringToolsTest.java | 18 +++ .../camel/dsl/jbang/core/commands/tui/AiPanel.java | 7 +- .../dsl/jbang/core/commands/tui/McpFacade.java | 16 +- .../jbang/core/commands/tui/TuiToolRegistry.java | 7 +- .../jbang/core/commands/tui/AiPanelAcpTest.java | 2 +- .../core/commands/tui/AiPanelPromptBudgetTest.java | 4 +- .../core/commands/tui/McpFacadeWriteFileTest.java | 26 +++ 15 files changed, 432 insertions(+), 28 deletions(-) diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc index 003caaa164bb..a233231d57ef 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc @@ -2183,6 +2183,9 @@ The authoring set has a new tool, `camel_edit_file`: it replaces one snippet of and what to put there, and validates and reloads the result as a write does. A model that rewrites a whole file to change one step corrupts the lines it did not mean to touch, so this is the tool for a change to an existing file; `camel_write_file` writes a new one. +Several places of one file are changed in one call with its `edits` argument (a list of find and replace), so the +file is written and reloaded once, not half done in between. A write or an edit whose route sends to a `direct:` +endpoint no route consumes yet answers with a note saying so. `camel_eval_expression` evaluates any language, not only the ones on the server's own classpath: the component of a language such as `jsonpath`, `jq` or `xpath` is downloaded on first use, as `camel run` downloads what a route diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc index 7b51b46e0c83..3caabf9a0d22 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc @@ -354,6 +354,11 @@ project `directory` as an argument, the runtime tools take the integration `name Validated and reloaded as a write is, and the answer says at which line it changed. A model that rewrites a whole file to add one step corrupts the lines it did not mean to touch, so this is the tool for a change to an existing file; `camel_write_file` writes a new one. + A change to several places of one file goes in one call, as `edits` (a list of find and replace, applied in + order): the file is validated, written and reloaded once, never half done, and when one of them does not match + the file is not changed and the answer says which. A route that sends to a `direct:` endpoint no route consumes + yet gets a note in the answer: in dev mode the messages sent there wait for the route, and the reload that adds it + cuts them off, so the route belongs in the same call. | `camel_run` | Starts an integration from a project directory with `camel run --source-dir` in a separate process, in dev diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc index af88d7ee0468..3ba4c8e8cba2 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc @@ -249,7 +249,7 @@ told what became of it with your next question. An edit (`camel_edit_file`, which the agent uses to change a file without rewriting it) goes the same way: the snippet is replaced in the file's content and the result is confirmed or replayed like any write, so what you see -in the editor is the finished file either way. +in the editor is the finished file either way. An edit with several changes (`edits`) is one change to confirm. While the replay pauses you can also ask the agent about the change it just made: *F8* opens the AI panel with the question prefilled (`About edit 2 of 3:`), in a compact panel that leaves the edit in view (*Shift+F8* diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java index cf20eaa21f45..655ca21a45e4 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java @@ -85,7 +85,12 @@ public class AskTools { } else { Map<String, JsonObject> props = new LinkedHashMap<>(); for (ToolDescriptor.Param p : td.params()) { - props.put(p.name(), stringProp(p.description())); + JsonObject prop = stringProp(p.description()); + if (p.items() != null) { + prop.put("type", "array"); + prop.put("items", p.items()); + } + props.put(p.name(), prop); } params = objectParams(props); } @@ -143,7 +148,10 @@ public class AskTools { if (args != null) { for (String key : args.keySet()) { Object val = args.get(key); - if (val != null) { + if (val instanceof Map<?, ?> || val instanceof List<?>) { + // e.g. the edits of camel_edit_file: the shared tools read JSON + argMap.put(key, Jsoner.serialize(val)); + } else if (val != null) { argMap.put(key, val.toString()); } } diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java index 6a51293e425e..ae6438e31bcb 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java @@ -30,13 +30,16 @@ import java.util.Collection; import java.util.Comparator; import java.util.EnumSet; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.function.Consumer; +import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.apache.camel.catalog.CamelCatalog; import org.apache.camel.dsl.jbang.core.common.RuntimeHelper; import org.apache.camel.util.json.JsonArray; import org.apache.camel.util.json.JsonObject; @@ -61,6 +64,10 @@ public final class AuthoringTools { static final String NAME_DESC = "Integration name or pid (default: the selected one, or the only one running)"; static final String VERSION_DESC = "Camel version to answer for (default: the CLI's own, or the selected integration's)"; + public static final String EDITS_DESC + = "Several changes to the file in one call, applied in order and written once, so it is never reloaded" + + " half done, e.g. a to: direct:x and the route from direct:x:" + + " [{\"find\": \"...\", \"replace\": \"...\"}, {\"find\": \"...\", \"replace\": \"...\"}]"; static final String DIRECTORY_DESC = "Project directory with the source files (default: the selected integration's)"; /** Files listed and read by the file tools; more than that and a directory is not an integration's sources. */ @@ -239,23 +246,26 @@ public final class AuthoringTools { })); registry.accept(tool("camel_edit_file", - "Changes a file by replacing one snippet: the exact text to find (it must occur once) and what to " + "Changes a file by replacing a snippet: the exact text to find (it must occur once) and what to " + "put there. Validated and reloaded as a write is. Use it to change an existing file, " - + "camel_write_file for a new one.") + + "camel_write_file for a new one. A change to several places of the file is given " + + "at once in edits, so the file is written and reloaded once, not half done.") .param("directory", "string", DIRECTORY_DESC, false) .param("file", "string", FILE_PATH_DESC, true) .param("find", "string", "The lines to replace as they stand in the file; other indentation is fine " - + "when the lines name one place", - true) - .param("replace", "string", "The text to put there; empty removes it", true) + + "when the lines name one place (or use edits)", + false) + .param("replace", "string", "The text to put there; empty removes it", false) + .arrayParam("edits", EDITS_DESC, false, "find", "The lines to replace as they stand in the file", + "replace", "The text to put there; empty removes it") .param("camelVersion", "string", VERSION_DESC, false) .readOnly(false) .core(true) .executor((ctx, args) -> { applyVersion(ctx, args); Path dir = ctx.resolveDirectory(args.get("directory")); - return editFile(ctx, dir, required(args, "file"), required(args, "find"), - args.get("replace") == null ? "" : args.get("replace")).toJson(); + return editFile(ctx, dir, required(args, "file"), args.get("find"), + args.get("replace") == null ? "" : args.get("replace"), args.get("edits")).toJson(); })); registry.accept(tool("camel_run", @@ -607,7 +617,12 @@ public final class AuthoringTools { private static final int EDIT_WINDOW_LINES = 20; public static JsonObject editFile(ToolContext ctx, Path dir, String file, String find, String replace) { - JsonObject edit = editedContent(dir, file, find, replace); + return editFile(ctx, dir, file, find, replace, null); + } + + /** As {@link #editFile(ToolContext, Path, String, String, String)} with several edits, written once. */ + public static JsonObject editFile(ToolContext ctx, Path dir, String file, String find, String replace, String edits) { + JsonObject edit = editedContent(dir, file, find, replace, edits); String content = edit.getString("content"); if (content == null) { return edit; // not-found or ambiguous: the answer says what to do instead @@ -617,6 +632,10 @@ public final class AuthoringTools { result.put("status", "edited"); result.put("editedAtLine", edit.getInteger("editedAtLine")); result.put("replacedLines", edit.getInteger("replacedLines")); + if (edit.get("edits") != null) { + result.put("edits", edit.get("edits")); + result.put("editedAtLines", edit.get("editedAtLines")); + } } else { result.put("message", "The file was not changed: the result has validation errors. Fix them and call" + " camel_edit_file again."); @@ -630,19 +649,106 @@ public final class AuthoringTools { * TUI writes that content itself, so an edit is confirmed and replayed in the editor like a write. */ public static JsonObject editedContent(Path dir, String file, String find, String replace) { + return editedContent(dir, file, find, replace, null); + } + + /** + * As {@link #editedContent(Path, String, String, String)} for several snippets of one file: find/replace (when + * given) and then each edit of {@code edits} (a JSON list of find and replace) are applied in order to the content, + * so the file is written, and an integration in dev mode reloaded, once. A change made in two calls is reloaded + * half done in between: a route that sends to a direct endpoint whose route the next call adds waits for it, and is + * cut off by that reload (CAMEL-25501). All or nothing: the answer of the first edit that misses, saying which one, + * and the file is not changed. + */ + public static JsonObject editedContent(Path dir, String file, String find, String replace, String edits) { Path path = resolveFile(dir, file); if (!Files.isRegularFile(path)) { throw new ToolExecutionException(file + " does not exist: write the whole file with camel_write_file"); } + List<String[]> all = new ArrayList<>(); + if (find != null && !find.isEmpty()) { + all.add(new String[] { find, replace == null ? "" : replace }); + } + all.addAll(parseEdits(edits)); + if (all.isEmpty()) { + throw new ToolExecutionException( + "find is required: the text to replace, as it stands in the file (or" + + " edits, a list of find and replace for several places)"); + } String content; try { content = Files.readString(path, StandardCharsets.UTF_8); } catch (IOException e) { throw new ToolExecutionException("Failed to read " + path + ": " + e.getMessage()); } - if (find == null || find.isEmpty()) { - throw new ToolExecutionException("find is required: the text to replace, as it stands in the file"); + JsonObject result = null; + JsonArray lines = new JsonArray(); + int replaced = 0; + for (int i = 0; i < all.size(); i++) { + JsonObject one = editedSnippet(file, content, all.get(i)[0], all.get(i)[1]); + if (one.getString("content") == null) { + if (all.size() > 1) { + one.put("edit", i + 1); + one.put("edits", all.size()); + one.put("message", "Edit " + (i + 1) + " of " + all.size() + ": " + one.getString("message") + + (i > 0 ? " (the file as the edits before it leave it)" : "") + + ". None of the edits was made: the file is unchanged."); + } + return one; + } + content = one.getString("content"); + lines.add(one.getInteger("editedAtLine")); + replaced += one.getInteger("replacedLines"); + if (result == null) { + result = one; + } + } + result.put("content", content); + result.put("replacedLines", replaced); + if (all.size() > 1) { + result.put("edits", all.size()); + result.put("editedAtLines", lines); + } + return result; + } + + /** The edits argument: a JSON list of objects with find and replace (or that list as a JSON string). */ + static List<String[]> parseEdits(String edits) { + List<String[]> answer = new ArrayList<>(); + if (edits == null || edits.isBlank()) { + return answer; + } + Object parsed; + try { + parsed = Jsoner.deserialize(edits); + if (parsed instanceof String text) { + // the list sent as a string holding JSON + parsed = Jsoner.deserialize(text); + } + } catch (Exception e) { + parsed = null; + } + if (parsed instanceof Map<?, ?> single) { + parsed = List.of(single); + } + if (!(parsed instanceof List<?> list)) { + throw new ToolExecutionException(EDITS_SHAPE); + } + for (Object item : list) { + if (!(item instanceof Map<?, ?> m) || !(m.get("find") instanceof String f) || f.isEmpty()) { + throw new ToolExecutionException(EDITS_SHAPE); + } + answer.add(new String[] { f, m.get("replace") instanceof String r ? r : "" }); } + return answer; + } + + private static final String EDITS_SHAPE + = "edits must be a list of {\"find\": \"the text as it stands in the file\", \"replace\": \"the text to put" + + " there\"}"; + + /** One snippet replaced in the content: the {@link #editedContent(Path, String, String, String)} answer. */ + private static JsonObject editedSnippet(String file, String content, String find, String replace) { String wanted = find; String put = replace; boolean trimmedMatch = false; @@ -1009,8 +1115,15 @@ public final class AuthoringTools { // written with problems the file already had: said, so they are not taken for fixed result.put("existingProblems", new JsonArray(problemsBefore)); } + String waiting = null; if (validate) { putKameletNotes(result, file, content); + waiting = unconsumedDirectNote(dir, file, content, ctx.catalog()); + if (waiting != null) { + JsonArray notes = result.get("notes") instanceof JsonArray existing ? existing : new JsonArray(); + notes.add(waiting); + result.put("notes", notes); + } } if (watch) { JsonObject reload = ReloadOutcome.await(ctx.pid(), processName, sinceKey, RELOAD_WAIT_MILLIS); @@ -1027,9 +1140,50 @@ public final class AuthoringTools { result.put("message", "An integration running the file in dev mode reloads it now; otherwise restart the" + " integration for the change to take effect."); } + if (waiting != null) { + result.put("message", result.getString("message") + " Note: " + waiting.substring(0, waiting.indexOf(" (")) + + " (see notes)."); + } return result; } + private static final Pattern UNCONSUMED_DIRECT = Pattern.compile("sends to (direct:[^,\\s]+), and no route consumes it"); + + /** + * The direct: endpoints a YAML route file just written sends to and no route consumes yet, as a note: not an error, + * the route is often a file or an edit still to come (CAMEL-24955), but until it is there the exchanges sent to it + * wait for it, and the reload that adds it cuts them off. Said at the write, the next change goes in one call with + * camel_edit_file edits (CAMEL-25501). Null when there is none. + */ + public static String unconsumedDirectNote(Path dir, String file, String content, CamelCatalog catalog) { + String name = file != null ? file.toLowerCase(Locale.ROOT) : ""; + if (!(name.endsWith(".yaml") || name.endsWith(".yml")) || name.endsWith(".kamelet.yaml") + || name.endsWith(".kamelet.yml")) { + return null; + } + Set<String> endpoints = new LinkedHashSet<>(); + try { + for (String msg : EndpointConsumerChecks.validateYamlConsumers(content, dir, file, catalog)) { + Matcher m = UNCONSUMED_DIRECT.matcher(msg); + if (m.find()) { + endpoints.add(m.group(1)); + } + } + } catch (RuntimeException e) { + // a note only: a content the check cannot read has no note + return null; + } + if (endpoints.isEmpty()) { + return null; + } + String list = String.join(", ", endpoints); + return list + (endpoints.size() == 1 ? " has" : " have") + " no route consuming " + + (endpoints.size() == 1 ? "it" : "them") + " yet (not in this file, nor in the other route files):" + + " in dev mode the messages sent there wait for one, and are cut off when a later save reloads this" + + " route. If the route goes in this file, add it together with the change that sends to it, in one" + + " camel_edit_file call with edits; if it goes in another file, write that file next."; + } + /** * The files of a project directory, subdirectories included, as {@code camel_get_files} lists them. A human answers * "which file has the route" with one {@code ls -R}; this gives a model the same in one call: the layout diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolDescriptor.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolDescriptor.java index 08b7881473ff..8aa4ff60ff48 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolDescriptor.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolDescriptor.java @@ -42,7 +42,14 @@ public class ToolDescriptor { private String[] deterministicUnless = new String[0]; private String repeatHint; - public record Param(String name, String type, String description, boolean required) { + /** + * A parameter of the tool; {@code items} is the schema of the elements of an array parameter (else null). + */ + public record Param(String name, String type, String description, boolean required, JsonObject items) { + + public Param(String name, String type, String description, boolean required) { + this(name, type, description, required, null); + } } @FunctionalInterface @@ -67,6 +74,28 @@ public class ToolDescriptor { return this; } + /** + * An array parameter whose elements are objects with the given string properties (name and description), all + * required. A client sends it as a JSON array; the executor gets it as JSON text. + */ + public ToolDescriptor arrayParam(String name, String description, boolean required, String... properties) { + JsonObject props = new JsonObject(); + JsonArray names = new JsonArray(); + for (int i = 0; i + 1 < properties.length; i += 2) { + JsonObject prop = new JsonObject(); + prop.put("type", "string"); + prop.put("description", properties[i + 1]); + props.put(properties[i], prop); + names.add(properties[i]); + } + JsonObject items = new JsonObject(); + items.put("type", "object"); + items.put("properties", props); + items.put("required", names); + params.add(new Param(name, "array", description, required, items)); + return this; + } + public ToolDescriptor readOnly(boolean v) { readOnly = v; return this; @@ -193,6 +222,9 @@ public class ToolDescriptor { JsonObject prop = new JsonObject(); prop.put("type", p.type() != null ? p.type() : "string"); prop.put("description", p.description()); + if (p.items() != null) { + prop.put("items", p.items()); + } properties.put(p.name(), prop); if (p.required()) { required.add(p.name()); diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsEditTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsEditTest.java index dd0b4a3209f7..3ba478e272f4 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsEditTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsEditTest.java @@ -20,7 +20,9 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import org.apache.camel.util.json.JsonArray; import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -229,4 +231,114 @@ class AuthoringToolsEditTest { .isInstanceOf(ToolExecutionException.class) .hasMessageContaining("camel_write_file"); } + + private static JsonObject edits(Path dir, String edits) { + return AuthoringTools.editFile(new ToolContext(), dir, "demo.camel.yaml", null, null, edits); + } + + /** CAMEL-25501: two places of the file changed in one call, written once, so it is never reloaded half done. */ + @Test + void severalEditsAreAppliedInOrderAndWrittenOnce(@TempDir Path dir) throws IOException { + Files.writeString(dir.resolve("demo.camel.yaml"), ROUTE); + + JsonArray list = new JsonArray(); + list.add(change("message: \"one\"", + "message: \"one\"\n - to:\n uri: direct:check")); + list.add(change("message: \"two\"", "message: \"checked ${body}\"")); + JsonObject result = edits(dir, list.toJson()); + + assertThat(result.getString("status")).as(result.toJson()).isEqualTo("edited"); + assertThat(result.getInteger("edits")).isEqualTo(2); + assertThat(result.getInteger("editedAtLine")).isEqualTo(11); + assertThat(((JsonArray) result.get("editedAtLines"))).containsExactly(11, 21); + String after = Files.readString(dir.resolve("demo.camel.yaml")); + assertThat(after).contains("uri: direct:check\n").contains("message: \"checked ${body}\"") + .doesNotContain("message: \"two\""); + } + + @Test + void anEditThatMissesLeavesTheFileUnchangedAndSaysWhichOne(@TempDir Path dir) throws IOException { + Files.writeString(dir.resolve("demo.camel.yaml"), ROUTE); + + JsonArray list = new JsonArray(); + list.add(change("message: \"one\"", "message: \"uno\"")); + list.add(change("message: \"three\"", "message: \"tres\"")); + JsonObject result = edits(dir, list.toJson()); + + assertThat(result.getString("status")).isEqualTo("not-found"); + assertThat(result.getInteger("edit")).isEqualTo(2); + assertThat(result.getString("message")).startsWith("Edit 2 of 2: ") + .endsWith("None of the edits was made: the file is unchanged."); + assertThat(Files.readString(dir.resolve("demo.camel.yaml"))).isEqualTo(ROUTE); + } + + @Test + void editsSentAsAStringHoldingJsonAreRead(@TempDir Path dir) throws IOException { + Files.writeString(dir.resolve("demo.camel.yaml"), ROUTE); + + JsonArray list = new JsonArray(); + list.add(change("message: \"two\"", "message: \"deux\"")); + // a model that builds the list as a JSON string sends it encoded once more + JsonObject result = edits(dir, Jsoner.serialize(list.toJson())); + + assertThat(result.getString("status")).isEqualTo("edited"); + assertThat(Files.readString(dir.resolve("demo.camel.yaml"))).contains("message: \"deux\""); + } + + @Test + void editsOfAnotherShapeSayWhatIsExpected(@TempDir Path dir) throws IOException { + Files.writeString(dir.resolve("demo.camel.yaml"), ROUTE); + + assertThatThrownBy(() -> edits(dir, "[\"message: two\"]")) + .isInstanceOf(ToolExecutionException.class) + .hasMessageContaining("edits must be a list of {\"find\""); + assertThatThrownBy(() -> AuthoringTools.editFile(new ToolContext(), dir, "demo.camel.yaml", null, null, null)) + .isInstanceOf(ToolExecutionException.class) + .hasMessageContaining("find is required"); + } + + @Test + void theToolListsEditsAsAnArrayOfFindAndReplace() { + JsonObject edits = (JsonObject) ((JsonObject) ToolRegistry.findTool("camel_edit_file").inputSchema() + .get("properties")).get("edits"); + + assertThat(edits.getString("type")).isEqualTo("array"); + JsonObject items = (JsonObject) edits.get("items"); + assertThat(((JsonObject) items.get("properties")).keySet()).containsExactly("find", "replace"); + assertThat(ToolRegistry.findTool("camel_edit_file").params()) + .noneMatch(p -> p.required() && (p.name().equals("find") || p.name().equals("replace"))); + } + + /** CAMEL-25501: the half-done edit is told, so the next change goes in one call. */ + @Test + void sendingToADirectEndpointNoRouteConsumesGetsANote(@TempDir Path dir) throws IOException { + Files.writeString(dir.resolve("demo.camel.yaml"), ROUTE); + + JsonObject half = edit(dir, "message: \"two\"", "message: \"two\"\n - to:\n uri: direct:ship"); + + assertThat(half.getString("status")).isEqualTo("edited"); + JsonArray notes = (JsonArray) half.get("notes"); + assertThat(notes).hasSize(1); + assertThat(notes.get(0).toString()) + .startsWith("direct:ship has no route consuming it yet").contains("camel_edit_file call with edits"); + assertThat(half.getString("message")).endsWith("Note: direct:ship has no route consuming it yet (see notes)."); + + JsonArray list = new JsonArray(); + list.add(change("uri: direct:ship", "uri: direct:shipment")); + list.add(change("message: \"one\"\n", + "message: \"one\"\n\n- route:\n id: shipment\n from:\n uri: direct:shipment\n" + + " steps:\n - log:\n message: shipped\n")); + JsonObject whole = edits(dir, list.toJson()); + + assertThat(whole.getString("status")).as(whole.toJson()).isEqualTo("edited"); + assertThat(whole.get("notes")).isNull(); + assertThat(whole.getString("message")).doesNotContain("Note:"); + } + + private static JsonObject change(String find, String replace) { + JsonObject jo = new JsonObject(); + jo.put("find", find); + jo.put("replace", replace); + return jo; + } } diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java index 80327185eb75..2944493f7218 100644 --- a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java +++ b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java @@ -17,6 +17,7 @@ package org.apache.camel.dsl.jbang.core.commands.mcp; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import jakarta.enterprise.context.ApplicationScoped; @@ -30,6 +31,7 @@ import io.quarkiverse.mcp.server.ToolCallException; import org.apache.camel.dsl.jbang.core.commands.ai.ToolContext; import org.apache.camel.dsl.jbang.core.commands.ai.ToolExecutionException; import org.apache.camel.dsl.jbang.core.commands.ai.ToolRegistry; +import org.apache.camel.util.json.JsonArray; import org.apache.camel.util.json.JsonObject; /** @@ -171,19 +173,43 @@ public class AuthoringTools { } @Tool(annotations = @Tool.Annotations(readOnlyHint = false, destructiveHint = false, openWorldHint = false), - description = "Changes a file by replacing one snippet: the exact text to find (it must occur once) and " + description = "Changes a file by replacing a snippet: the exact text to find (it must occur once) and " + "what to put there. Validated and reloaded as a write is. Use it to change an existing file, " - + "camel_write_file for a new one.") + + "camel_write_file for a new one. A change to several places of the file is given at once in " + + "edits, so the file is written and reloaded once, not half done.") public JsonObject camel_edit_file( @ToolArg(description = DIRECTORY_DESC, required = false) String directory, @ToolArg(description = "File path relative to the directory", required = true) String file, @ToolArg(description = "The lines to replace as they stand in the file; other indentation is fine when " - + "the lines name one place", - required = true) String find, - @ToolArg(description = "The text to put there; empty removes it", required = true) String replace, + + "the lines name one place (or use edits)", + required = false) String find, + @ToolArg(description = "The text to put there; empty removes it", required = false) String replace, + @ToolArg(description = org.apache.camel.dsl.jbang.core.commands.ai.AuthoringTools.EDITS_DESC, + required = false) List<FileEdit> edits, @ToolArg(description = VERSION_DESC, required = false) String camelVersion) { return call("camel_edit_file", args("directory", directory, "file", file, "find", find, "replace", replace, - "camelVersion", camelVersion)); + "edits", editsJson(edits), "camelVersion", camelVersion)); + } + + /** One change of camel_edit_file's edits: the text to find as it stands in the file, and what to put there. */ + public record FileEdit(String find, String replace) { + } + + /** The edits as the JSON list the shared tool reads, or null when there are none. */ + static String editsJson(List<FileEdit> edits) { + if (edits == null || edits.isEmpty()) { + return null; + } + JsonArray list = new JsonArray(); + for (FileEdit edit : edits) { + JsonObject jo = new JsonObject(); + if (edit != null) { + jo.put("find", edit.find()); + jo.put("replace", edit.replace()); + } + list.add(jo); + } + return list.toJson(); } @Tool(annotations = @Tool.Annotations(readOnlyHint = false, destructiveHint = false, openWorldHint = true), diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java index a552525e3b22..3c4250eb3845 100644 --- a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java +++ b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java @@ -109,6 +109,24 @@ class AuthoringToolsTest { .isInstanceOf(ToolCallException.class).hasMessageContaining("directory is required"); } + /** CAMEL-25501: several places of one file in one call, written once. */ + @Test + void severalEditsOfOneFileAreWrittenOnce(@TempDir Path dir) throws Exception { + String route = "- route:\n from:\n uri: timer:tick\n steps:\n - log:\n message: hi\n" + + " - log:\n message: bye\n"; + Files.writeString(dir.resolve("demo.camel.yaml"), route, StandardCharsets.UTF_8); + + JsonObject edited = tools.camel_edit_file(dir.toString(), "demo.camel.yaml", null, null, + List.of(new AuthoringTools.FileEdit("message: hi", "message: hello"), + new AuthoringTools.FileEdit("message: bye", "message: goodbye")), + null); + + assertThat(edited.getString("status")).isEqualTo("edited"); + assertThat(edited.getInteger("edits")).isEqualTo(2); + assertThat(Files.readString(dir.resolve("demo.camel.yaml"), StandardCharsets.UTF_8)) + .contains("message: hello").contains("message: goodbye"); + } + @Test void anUnknownIntegrationNameIsAnError() { assertThatThrownBy(() -> tools.camel_eval_expression("${body}", null, "camel", "no-such-app-xyz-1")) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java index b2e967781909..123292168428 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java @@ -3297,7 +3297,7 @@ class AiPanel { private String acpPreamble() { String prompt = buildSystemPrompt(); return (prompt.endsWith("\n") ? prompt : prompt + "\n") - + "- You run inside the Camel TUI: edit the integration's source files only with camel_write_file, " + + "- You run inside the Camel TUI: edit the integration's source files only with camel_edit_file or camel_write_file, " + "never with your own file tools, so the user sees the diff and confirms it, or watches the edit " + "being typed in the Source editor (/write live)\n"; } @@ -3332,11 +3332,12 @@ class AiPanel { sb.append("say what failed and what to try\n"); sb.append("- To feed a route that consumes from a broker (MQTT, Kafka, JMS), tui_send_message can publish "); sb.append("to the broker with the route's own component and options\n"); - sb.append("- To edit: camel_get_files, then camel_write_file with the complete file; the user confirms, never "); + sb.append("- To edit: camel_get_files, then camel_edit_file (several places at once with edits), camel_write_file "); + sb.append("for a new file; the user confirms, never "); sb.append( "retry a rejected write. Invalid YAML/properties is refused with errors: fix them (camel_catalog_doc has the "); sb.append("option names)\n"); - sb.append("- Write files only with camel_write_file; never paste file contents in the answer\n"); + sb.append("- Write files only with these tools; never paste file contents in the answer\n"); sb.append("- YAML DSL shape: a step is `- log: {message: ...}`, `- to: {uri: ...}`, an expression goes under "); sb.append("expression: (`- setBody: {expression: {simple: ...}}`); the shorthand forms are deprecated\n"); sb.append("- 'log at WARN' in a route is the log step's loggingLevel in the source"); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java index c2859f6e976c..400c1241339c 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java @@ -1388,6 +1388,11 @@ class McpFacade { describeSourceDirectory(target, dir, result); result.put("lines", lines); result.put("bytes", content.getBytes(StandardCharsets.UTF_8).length); + // a route sending to a direct: endpoint no route consumes yet: its route goes in the same call (CAMEL-25501) + String waiting = AuthoringTools.unconsumedDirectNote(dir, file, content, null); + if (waiting != null) { + result.put("notes", new JsonArray(List.of(waiting))); + } return result; } @@ -1397,6 +1402,11 @@ class McpFacade { * write (CAMEL-24909). The reading and the matching are the shared tool's, only the writing is the TUI's. */ JsonObject editFile(String name, String file, String find, String replace, boolean confirm) { + return editFile(name, file, find, replace, null, confirm); + } + + /** As {@link #editFile(String, String, String, String, boolean)} with several edits, written once (CAMEL-25501). */ + JsonObject editFile(String name, String file, String find, String replace, String edits, boolean confirm) { IntegrationInfo target = findIntegration(name); if (target == null) { return writeError(name != null && !name.isEmpty() @@ -1406,7 +1416,7 @@ class McpFacade { if (dir == null || !Files.isDirectory(dir)) { return writeError("No source directory found for the integration"); } - JsonObject edit = AuthoringTools.editedContent(dir, file, find, replace); + JsonObject edit = AuthoringTools.editedContent(dir, file, find, replace, edits); String content = edit.getString("content"); if (content == null) { return edit; // not-found, ambiguous or an error: the shared answer says what to do @@ -1416,6 +1426,10 @@ class McpFacade { result.put("status", "edited"); result.put("editedAtLine", edit.getInteger("editedAtLine")); result.put("replacedLines", edit.getInteger("replacedLines")); + if (edit.get("edits") != null) { + result.put("edits", edit.get("edits")); + result.put("editedAtLines", edit.get("editedAtLines")); + } } return result; } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java index 4061f84e11e1..4fe764360ee7 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java @@ -1490,9 +1490,12 @@ class TuiToolRegistry { String file = args.get("file") instanceof String s ? s : null; String find = args.get("find") instanceof String s ? s : null; String replace = args.get("replace") instanceof String s ? s : ""; + Object list = args.get("edits"); + String edits = list instanceof Map<?, ?> || list instanceof List<?> ? Jsoner.serialize(list) + : list instanceof String text ? text : null; boolean confirm = !Boolean.FALSE.equals(args.get("confirm")); - // an edit is confirmed and replayed in the editor like a write (CAMEL-24909) - return Jsoner.serialize(facade.editFile(name, file, find, replace, confirm)); + // an edit is confirmed and replayed in the editor like a write (CAMEL-24909), several edits as one (CAMEL-25501) + return Jsoner.serialize(facade.editFile(name, file, find, replace, edits, confirm)); } private String callValidateSource(Map<String, Object> args) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelAcpTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelAcpTest.java index 4902bf9cd52c..6681b0ee5793 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelAcpTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelAcpTest.java @@ -603,7 +603,7 @@ class AiPanelAcpTest { ask(panel, "hi"); awaitIdle(panel); String prompt = promptText(agent.received("session/prompt").get(0)); - assertTrue(prompt.contains("only with camel_write_file"), prompt); + assertTrue(prompt.contains("only with camel_edit_file or camel_write_file"), prompt); assertTrue(prompt.contains("/write live"), prompt); } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelPromptBudgetTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelPromptBudgetTest.java index 1237a0b52474..7bd7969a8983 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelPromptBudgetTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelPromptBudgetTest.java @@ -60,7 +60,9 @@ class AiPanelPromptBudgetTest { // full mode only (hosted models), and the panel's own /overview sends no tools at all // raised from 9850 for tui_http_endpoints and tui_http_request (CAMEL-25307), measured ~10070: a model that can // see what the integration serves and call it is worth the ~250 tokens - static final int FULL_BUDGET_TOKENS = 10_250; + // raised from 10250 for the edits argument of camel_edit_file (CAMEL-25501), measured ~10400: several places of a + // file changed in one call, so dev mode never reloads a file half done + static final int FULL_BUDGET_TOKENS = 10_500; /** Measured ~5.5k tokens for 28 tools: the core set (~4.7k) plus every tool group (CAMEL-24834). */ // the SQL group adds tui_execute_sql and tui_update_row (~385 tokens), each group one guidance line in the prompt // (~130 for all three); an integration rarely has all of them, and the groups only load for the integration that diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeWriteFileTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeWriteFileTest.java index 5422c4dddf7c..cffa670ff52d 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeWriteFileTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeWriteFileTest.java @@ -30,6 +30,7 @@ import org.junit.jupiter.api.io.TempDir; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; class McpFacadeWriteFileTest { @@ -203,6 +204,31 @@ class McpFacadeWriteFileTest { assertEquals(1, bridge.asked, "nothing to confirm when the snippet is not there"); } + /** CAMEL-25501: several edits are one change to confirm; a send to a direct: no route consumes yet is noted. */ + @Test + void severalEditsAreOneConfirmedChangeAndAnUnconsumedDirectIsNoted(@TempDir Path dir) throws IOException { + Files.writeString(dir.resolve("demo.camel.yaml"), "- route:\n id: x\n from:\n uri: direct:x\n" + + " steps:\n - log:\n message: hi\n"); + ConfirmingBridge bridge = new ConfirmingBridge(true); + McpFacade facade = facade(dir, true, bridge); + + JsonObject half = facade.editFile("demo", "demo.camel.yaml", "message: hi", + "message: hi\n - to:\n uri: direct:ship", true); + assertEquals("edited", half.getString("status")); + assertTrue(half.get("notes").toString().contains("direct:ship has no route consuming it yet"), half.toJson()); + + String edits = "[{\"find\": \"id: x\", \"replace\": \"id: picked\"}," + + " {\"find\": \"uri: direct:ship\\n\", \"replace\": \"uri: direct:ship\\n\\n- route:\\n id: ship\\n" + + " from:\\n uri: direct:ship\\n steps:\\n - log:\\n message: shipped\\n\"}]"; + JsonObject whole = facade.editFile("demo", "demo.camel.yaml", null, null, edits, true); + assertEquals("edited", whole.getString("status"), whole.toJson()); + assertEquals(2, whole.getInteger("edits")); + assertEquals(2, bridge.asked, "the two edits are confirmed as one change"); + assertNull(whole.get("notes"), whole.toJson()); + String after = Files.readString(dir.resolve("demo.camel.yaml"), StandardCharsets.UTF_8); + assertTrue(after.contains("id: picked") && after.contains("id: ship"), after); + } + @Test void invalidContentIsNeverWritten(@TempDir Path dir) throws IOException { Files.writeString(dir.resolve("demo.camel.yaml"), "- route: {}\n");
