This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new ab68fe253780 CAMEL-25501: camel-jbang-mcp - camel_edit_file can apply
several edits to one file in one call (#27633)
ab68fe253780 is described below
commit ab68fe253780f03f70b4f12ba5ccab67177a49f8
Author: Claus Ibsen <[email protected]>
AuthorDate: Sat Oct 10 00:10:57 2026 +0200
CAMEL-25501: camel-jbang-mcp - camel_edit_file can apply several edits to
one file in one call (#27633)
* 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
* CAMEL-25501: the note's summary and text come from one place
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
* CAMEL-25501: say why a null edit is kept
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
.../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 | 180 +++++++++++++++++++--
.../dsl/jbang/core/commands/ai/ToolDescriptor.java | 34 +++-
.../core/commands/ai/AuthoringToolsEditTest.java | 112 +++++++++++++
.../jbang/core/commands/mcp/AuthoringTools.java | 40 ++++-
.../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, 440 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 f022c9640d33..dafc371e0f27 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
@@ -2209,6 +2209,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..453aed7c17bc 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));
}
+ UnconsumedDirect waiting = null;
if (validate) {
putKameletNotes(result, file, content);
+ waiting = unconsumedDirect(dir, file, content, ctx.catalog());
+ if (waiting != null) {
+ JsonArray notes = result.get("notes") instanceof JsonArray
existing ? existing : new JsonArray();
+ notes.add(waiting.note());
+ result.put("notes", notes);
+ }
}
if (watch) {
JsonObject reload = ReloadOutcome.await(ctx.pid(), processName,
sinceKey, RELOAD_WAIT_MILLIS);
@@ -1027,9 +1140,56 @@ 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.summary() + " (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 UnconsumedDirect unconsumedDirect(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 summary = String.join(", ", endpoints) + (endpoints.size() == 1
? " has" : " have")
+ + " no route consuming " + (endpoints.size() == 1 ?
"it" : "them") + " yet";
+ return new UnconsumedDirect(
+ summary, summary + " (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 direct: endpoints no route consumes yet: the short summary for the
message, and the whole note.
+ */
+ public record UnconsumedDirect(String summary, String note) {
+ }
+
/**
* 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..e854a23641ca 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,45 @@ 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) {
+ // a null element stays in the list as {}, on purpose: the shared
tool then answers with the shape edits
+ // must have, so a malformed call is told rather than half applied
+ 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..6b7bbd6a734a 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)
+ AuthoringTools.UnconsumedDirect waiting =
AuthoringTools.unconsumedDirect(dir, file, content, null);
+ if (waiting != null) {
+ result.put("notes", new JsonArray(List.of(waiting.note())));
+ }
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");