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 55dde649d405 CAMEL-25953: camel-jbang - AI tools: an unchanged write 
says so, the log marks records from before the last reload, and a missed edit 
shows the file as it is (#28270)
55dde649d405 is described below

commit 55dde649d4054a1d6bda1fac415e2375461e9d97
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 22 22:15:26 2026 +0200

    CAMEL-25953: camel-jbang - AI tools: an unchanged write says so, the log 
marks records from before the last reload, and a missed edit shows the file as 
it is (#28270)
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  14 +-
 .../dsl/jbang/core/commands/ai/AuthoringTools.java | 177 +++++++++++++++++++--
 .../dsl/jbang/core/commands/ai/LogFileReader.java  |   2 +-
 .../core/commands/ai/AuthoringToolsEditTest.java   |  29 +++-
 .../commands/ai/AuthoringToolsLenientTest.java     |  54 +++++++
 .../jbang/core/commands/ai/AuthoringToolsTest.java |  54 ++++++-
 .../jbang/core/commands/ai/LogFileReaderTest.java  |  19 +++
 7 files changed, 335 insertions(+), 14 deletions(-)

diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 29ad36b1652f..eb7e7c67263c 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -2335,8 +2335,18 @@ Content with more than one reading is refused as before.
 
 `camel_edit_file` now matches lines to find that are not in the file as given, 
as when a model writes them from memory,
 to the closest lines of the file when exactly one place matches well. It 
changes there the lines the model changed and
-keeps the file's version of the other lines. The edit stands only when its 
result is valid; otherwise the answer is
-the one of a miss, with the lines the file has there. `matchedLines` in the 
answer names the lines it matched.
+keeps the file's version of the other lines, and the lines of the file the 
model did not have. The edit stands only
+when its result is valid; otherwise the answer is the one of a miss, with the 
lines the file has there. `matchedLines`
+in the answer names the lines it matched.
+
+A write of the content the file already has, and an edit whose replacement 
gives the text the file has, answer with
+the status `unchanged` instead of `overwritten` or `edited`. Nothing is 
written, so a running integration does not
+reload. When one of several edits misses, the answer shows the file as it is 
in `fileWindow` and says that none of the
+edits was written, also not the ones before it that matched.
+
+`camel_get_log` marks the records from before the last reload of the routes 
with `beforeLastReload`, with a
+`reloadNote` first in the answer. The note of `camel_get_errors` on errors 
from before the last reload is now the first
+key of its answer, and says how many exchanges failed since.
 
 === camel-jbang - the console output of an integration that the AI tools start
 
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 31174238a18a..043f1e2f196a 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
@@ -356,8 +356,10 @@ public final class AuthoringTools {
                         // the process selected before is gone, such as one 
that died at startup
                         return previousLog(ctx, args, null).toJson();
                     }
-                    return LogFileReader.read(ctx.pid(), p != null ? p.name() 
: null, integer(args, "limit", 50),
-                            args.get("filter"), args.get("level"), bool(args, 
"details", false)).toJson();
+                    String name = p != null ? p.name() : null;
+                    return 
markLogBeforeLastReload(LogFileReader.read(ctx.pid(), name, integer(args, 
"limit", 50),
+                            args.get("filter"), args.get("level"), bool(args, 
"details", false)), ctx.pid(), name)
+                            .toJson();
                 }));
 
         registry.accept(tool("camel_get_errors",
@@ -698,7 +700,12 @@ public final class AuthoringTools {
                 return exact;
             }
         }
-        if (!"invalid".equals(result.getString("status"))) {
+        if ("unchanged".equals(result.getString("status"))) {
+            result.put("message", "The edit leaves the file as it is (the 
replacement gives the text the file already"
+                                  + " has), so nothing was written and nothing 
reloads. To change what the route does,"
+                                  + " change the text; camel_get_log and 
camel_get_errors show what the current version"
+                                  + " does.");
+        } else if (!"invalid".equals(result.getString("status"))) {
             result.put("status", "edited");
             if (edit.get("matchedLines") instanceof JsonArray matched) {
                 result.put("matchedLines", matched);
@@ -771,6 +778,7 @@ public final class AuthoringTools {
         } catch (IOException e) {
             throw new ToolExecutionException("Failed to read " + path + ": " + 
e.getMessage());
         }
+        String original = content;
         JsonObject result = null;
         JsonArray lines = new JsonArray();
         JsonArray matched = new JsonArray();
@@ -779,11 +787,25 @@ public final class AuthoringTools {
             JsonObject one = editedSnippet(file, content, all.get(i)[0], 
all.get(i)[1], closest);
             if (one.getString("content") == null) {
                 if (all.size() > 1) {
+                    if (i > 0) {
+                        // nothing is written, so the window shows the file as 
it is: a window with the edits before
+                        // made in it was read as those edits written, and 
only the one that missed was sent again
+                        // (r21 of the local-model benchmark)
+                        JsonObject asItIs = editedSnippet(file, original, 
all.get(i)[0], all.get(i)[1], closest);
+                        if (asItIs.getString("content") == null && 
asItIs.get("fileWindow") != null) {
+                            one = asItIs;
+                        }
+                    }
                     one.put("edit", i + 1);
                     one.put("edits", all.size());
+                    String earlier = i == 1 ? "edit 1, which matched" : "edits 
1 to " + i + ", which matched";
                     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.");
+                                       + ". None of the edits was written, so 
the file is unchanged"
+                                       + (i > 0
+                                               ? ", also not " + earlier + ": 
send all " + all.size() + " edits again in"
+                                                 + " one call, the text to 
find of each copied from the file as it is"
+                                                 + " (fileWindow)."
+                                               : "."));
                 }
                 return one;
             }
@@ -1207,9 +1229,11 @@ public final class AuthoringTools {
                 // the depth of the replacement where it goes: from the anchor 
before it, or after it for the first
                 int d = pf >= 0 ? leadingSpaces(lines[fw[pf]]) - 
leadingSpaces(f.get(pf))
                         : a[0] < f.size() ? leadingSpaces(lines[a[1]]) - 
leadingSpaces(f.get(a[0])) : firstDelta;
+                List<String> replacement = new ArrayList<>();
                 for (String line : r.subList(pr + 1, a[2])) {
-                    out.add(shiftIndent(line, d));
+                    replacement.add(shiftIndent(line, d));
                 }
+                out.addAll(mergeStretch(f.subList(pf + 1, a[0]), lines, pw + 
1, a[1], d, replacement));
             }
             if (a[0] < f.size()) {
                 out.add(lines[a[1]]);
@@ -1225,6 +1249,47 @@ public final class AuthoringTools {
         return new ClosestEdit(start, end, String.join("\n", out) + (newline ? 
"\n" : ""), ws + 1, we + 1);
     }
 
+    /**
+     * The lines of a stretch of the file that the model changed: the lines of 
the file that find has (the same text, or
+     * the same key at the same depth) are replaced by replace's version, 
where the first of them stands, and the lines
+     * of the file that find does not have stay, as a parameter the model did 
not remember (r21 of the local-model
+     * benchmark: name: report of a cron endpoint was dropped). When find has 
lines here and none of them is in the
+     * file, the model remembered the stretch wrongly, and replace takes its 
place.
+     */
+    private static List<String> mergeStretch(
+            List<String> find, String[] lines, int from, int to, int delta, 
List<String> replacement) {
+        boolean[] known = new boolean[Math.max(0, to - from)];
+        boolean anyKnown = false;
+        int next = 0;
+        for (String line : find) {
+            for (int k = next; k < known.length; k++) {
+                if (sameLineOrKey(line, lines[from + k], delta)) {
+                    known[k] = true;
+                    anyKnown = true;
+                    next = k + 1;
+                    break;
+                }
+            }
+        }
+        if (!find.isEmpty() && !anyKnown) {
+            return replacement;
+        }
+        List<String> out = new ArrayList<>();
+        boolean placed = false;
+        for (int k = 0; k < known.length; k++) {
+            if (!known[k]) {
+                out.add(lines[from + k]);
+            } else if (!placed) {
+                out.addAll(replacement);
+                placed = true;
+            }
+        }
+        if (!placed) {
+            out.addAll(replacement);
+        }
+        return out;
+    }
+
     /** Which line of b each line of a is (their longest common subsequence), 
or -1; empty lines match nothing. */
     static int[] alignLines(List<String> a, List<String> b) {
         int[][] t = new int[a.size() + 1][b.size() + 1];
@@ -1622,6 +1687,19 @@ public final class AuthoringTools {
                 return result;
             }
         }
+        if (exists && content.equals(read(path))) {
+            // the content the file has: nothing to write and no reload to 
wait for. Told "overwritten", a model wrote
+            // one file unchanged up to 24 times in a row (r20 and r21 of the 
local-model benchmark)
+            LAST_REFUSED.remove(path.toAbsolutePath().normalize());
+            JsonObject result = new JsonObject();
+            result.put("status", "unchanged");
+            result.put("file", file);
+            result.put("directory", dir.toString());
+            result.put("message", "The file already has exactly this content, 
so nothing was written and nothing"
+                                  + " reloads. To change what the route does, 
change the content; camel_get_log and"
+                                  + " camel_get_errors show what the current 
version does.");
+            return result;
+        }
         // a client that asks before each write (camel ask, CAMEL-25676) shows 
the change first; the others write as
         // before
         if (ctx.confirmer() != null
@@ -2365,13 +2443,94 @@ public final class AuthoringTools {
             }
         }
         if (before > 0) {
-            errors.put("note", before + (before == 1 ? " exchange" : " 
exchanges") + " failed before the routes were last"
-                               + " reloaded (at " + reloadTime + "), so on an 
earlier version of the routes"
-                               + " (beforeLastReload); the errors are kept 
until the integration restarts");
+            int since = errors.get("errors") instanceof List<?> entries ? 
entries.size() - before : 0;
+            // first in the answer: the errors before it carry their stack 
traces, and a model that reads the first
+            // few thousand characters of a 60 KB answer never got to a note 
at the end (r21 of the local-model
+            // benchmark)
+            putFirst(errors, "note", before + (before == 1 ? " exchange" : " 
exchanges")
+                                     + " failed before the routes were last 
reloaded (at " + reloadTime + "), so on an"
+                                     + " earlier version of the routes 
(beforeLastReload), "
+                                     + (since == 0 ? "and none since" : since 
+ " since")
+                                     + "; the errors are kept until the 
integration restarts");
         }
         return errors;
     }
 
+    /** Puts a key first in an answer, in place: what is read first is the 
start of the answer. */
+    static void putFirst(JsonObject answer, String key, Object value) {
+        JsonObject rest = new JsonObject();
+        rest.putAll(answer);
+        rest.remove(key);
+        answer.clear();
+        answer.put(key, value);
+        answer.putAll(rest);
+    }
+
+    /**
+     * Marks the log records of an earlier version of the routes: those before 
the newest reload of the routes in the
+     * log. A model that read the ERROR records of camel_get_log after a 
change took the ones from before its change for
+     * errors of its new version, and changed a route that worked (r21 of the 
local-model benchmark).
+     */
+    static JsonObject markLogBeforeLastReload(JsonObject log, long pid, String 
name) {
+        try {
+            JsonObject reloads = LogFileReader.read(pid, name, 1, 
ReloadOutcome.RELOADED, null);
+            if (reloads.get("lines") instanceof List<?> lines && 
!lines.isEmpty()
+                    && lines.get(0) instanceof JsonObject reload) {
+                return markLogBefore(log, reload.getString("time"));
+            }
+        } catch (Exception e) {
+            // no log to tell: the records as they are
+        }
+        return log;
+    }
+
+    /** Marks the records before the reload at the given time of day; the log 
keeps the time of day only. */
+    static JsonObject markLogBefore(JsonObject log, String reloadTime) {
+        long reload;
+        try {
+            // the summary is logged after the new routes started: a margin 
keeps a route that fails at once unmarked
+            reload = LocalTime.parse(reloadTime).toNanoOfDay() - 
RELOAD_MARGIN_MILLIS * 1_000_000L;
+        } catch (Exception e) {
+            return log;
+        }
+        long day = 24L * 3600 * 1_000_000_000L;
+        int before = 0;
+        int since = 0;
+        if (log.get("lines") instanceof List<?> rows) {
+            for (Object o : rows) {
+                if (!(o instanceof JsonObject r)) {
+                    continue;
+                }
+                long time;
+                try {
+                    time = LocalTime.parse(r.getString("time")).toNanoOfDay();
+                } catch (Exception e) {
+                    continue;
+                }
+                // a log that runs past midnight: the nearer of the two days
+                long ahead = reload - time;
+                if (ahead > day / 2) {
+                    ahead -= day;
+                } else if (ahead < -day / 2) {
+                    ahead += day;
+                }
+                if (ahead > 0) {
+                    r.put("beforeLastReload", true);
+                    before++;
+                } else {
+                    since++;
+                }
+            }
+        }
+        if (before > 0) {
+            putFirst(log, "reloadNote", before + (before == 1 ? " record is" : 
" records are")
+                                        + " from before the routes were last 
reloaded (at " + reloadTime + "), so from"
+                                        + " an earlier version of the routes 
(beforeLastReload)"
+                                        + (since == 0 ? "; none is from the 
current version" : ""));
+        }
+        return log;
+    }
+
     /**
      * The .camel folders under the directory, with the number of files in 
each: a file consumer moves a file it read
      * into .camel (its default, without noop=true), so a folder whose files 
were all read looked empty.
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/LogFileReader.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/LogFileReader.java
index f67c1dc3cd8e..3390211ec021 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/LogFileReader.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/LogFileReader.java
@@ -48,7 +48,7 @@ public final class LogFileReader {
             "^(\\d{4}-\\d{2}-\\d{2})[T ](\\d{2}:\\d{2}:\\d{2}\\.\\d+)\\S*\\s+"
                                                                + 
"(TRACE|DEBUG|INFO|WARN|ERROR|FATAL)\\s+"
                                                                + 
"\\d+\\s+---\\s+"
-                                                               + 
"\\[([^]]*)]\\s+"
+                                                               + 
"\\[((?:[^\\[\\]]|\\[[^\\]]*])*)]\\s+"
                                                                + 
"(\\S+)\\s*:\\s*(.*)$");
 
     private static final Pattern ANSI = Pattern.compile("\\u001b\\[[;\\d]*[ 
-/]*[@-~]");
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 2784e3791b58..e889e69f975a 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
@@ -269,10 +269,37 @@ class AuthoringToolsEditTest {
         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.");
+                .endsWith("None of the edits was written, so the file is 
unchanged, also not edit 1, which matched: send"
+                          + " all 2 edits again in one call, the text to find 
of each copied from the file as it is"
+                          + " (fileWindow).");
+        // the window is the file as it is, without edit 1 in it: a window 
with edit 1 made in it was read as edit 1
+        // written, and only edit 2 was sent again (r21 of the local-model 
benchmark)
+        assertThat(result.getString("fileWindow")).contains("message: 
\"one\"").doesNotContain("uno");
         
assertThat(Files.readString(dir.resolve("demo.camel.yaml"))).isEqualTo(ROUTE);
     }
 
+    /** A write of the content the file has: nothing is written, and the 
answer says so. */
+    @Test
+    void aWriteOfTheContentTheFileHasIsUnchanged(@TempDir Path dir) throws 
IOException {
+        Path file = dir.resolve("demo.camel.yaml");
+        Files.writeString(file, ROUTE);
+        java.nio.file.attribute.FileTime before = 
java.nio.file.attribute.FileTime.fromMillis(1_000_000L);
+        Files.setLastModifiedTime(file, before);
+
+        JsonObject result = AuthoringTools.writeFile(new ToolContext(), dir, 
"demo.camel.yaml", ROUTE, true);
+
+        
assertThat(result.getString("status")).as(result.toJson()).isEqualTo("unchanged");
+        assertThat(result.getString("message")).startsWith("The file already 
has exactly this content, so nothing was"
+                                                           + " written and 
nothing reloads.");
+        assertThat(Files.getLastModifiedTime(file)).as("not written: no 
reload").isEqualTo(before);
+
+        // an edit whose replacement is the text the file has
+        JsonObject edit = edit(dir, "message: \"one\"", "message: \"one\"");
+        
assertThat(edit.getString("status")).as(edit.toJson()).isEqualTo("unchanged");
+        assertThat(edit.getString("message")).startsWith("The edit leaves the 
file as it is");
+        assertThat(Files.getLastModifiedTime(file)).isEqualTo(before);
+    }
+
     @Test
     void editsSentAsAStringHoldingJsonAreRead(@TempDir Path dir) throws 
IOException {
         Files.writeString(dir.resolve("demo.camel.yaml"), ROUTE);
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsLenientTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsLenientTest.java
index 0dad73019998..c3e0af46da87 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsLenientTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsLenientTest.java
@@ -488,6 +488,60 @@ class AuthoringToolsLenientTest {
                 .contains("message: \"archived\"");
     }
 
+    /**
+     * A line of the file that the snippet from memory does not have stays: 
the model remembered the cron endpoint
+     * without its name parameter, and the merge dropped name: report (r21 of 
the local-model benchmark).
+     */
+    @Test
+    void aLineOfTheFileTheSnippetDoesNotHaveStays(@TempDir Path dir) throws 
IOException {
+        String route = """
+                - route:
+                    id: nightly-report
+                    from:
+                      uri: cron
+                      parameters:
+                        name: report
+                        schedule: "0/10 * * * * ?"
+                      steps:
+                        - log:
+                            message: "Inventory report: 120 T-shirts and 45 
mugs in stock"
+                """;
+        Files.writeString(dir.resolve(FILE), route);
+        JsonObject result = AuthoringTools.editFile(new ToolContext(), dir, 
FILE, """
+                      uri: cron:report
+                      parameters:
+                        schedule: "0/10 * * * * ?"
+                """, """
+                      uri: cron:report
+                      parameters:
+                        schedule: "{{report.schedule}}"
+                """);
+        
assertThat(result.getString("status")).as(String.valueOf(result)).isEqualTo("edited");
+        
assertThat(Files.readString(dir.resolve(FILE))).isEqualTo(route.replace("\"0/10 
* * * * ?\"",
+                "\"{{report.schedule}}\""));
+    }
+
+    /** A line the model adds between two lines it remembers goes in, and the 
file's line between them stays. */
+    @Test
+    void aLineAddedBetweenTwoRememberedLinesKeepsTheFilesLineThere(@TempDir 
Path dir) throws IOException {
+        Files.writeString(dir.resolve(FILE), ROUTE);
+        JsonObject result = AuthoringTools.editFile(new ToolContext(), dir, 
FILE, """
+                    - unmarshal:
+                        json:
+                    - log:
+                        message: "Invoice ${body[invoiceId]} for 
${body[orderId]}"
+                """, """
+                    - unmarshal:
+                        json:
+                          allowUnmarshallType: true
+                    - log:
+                        message: "Invoice ${body[invoiceId]} for 
${body[orderId]}"
+                """);
+        
assertThat(result.getString("status")).as(String.valueOf(result)).isEqualTo("edited");
+        String after = Files.readString(dir.resolve(FILE));
+        assertThat(after).contains("library: 
Jackson").contains("allowUnmarshallType: true");
+    }
+
     @Test
     void aClosestMatchWithAnInvalidResultIsNotFound(@TempDir Path dir) throws 
IOException {
         Files.writeString(dir.resolve(FILE), ROUTE);
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
index e4bf42b3b783..61099db11db6 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
@@ -194,8 +194,60 @@ class AuthoringToolsTest {
         assertFalse(entries.get(1).containsKey("beforeLastReload"), "within a 
second of the reload: maybe the new routes");
         assertFalse(entries.get(2).containsKey("beforeLastReload"), "after the 
reload");
         assertEquals("1 exchange failed before the routes were last reloaded 
(at 08:18:09.731), so on an earlier version"
-                     + " of the routes (beforeLastReload); the errors are kept 
until the integration restarts",
+                     + " of the routes (beforeLastReload), 2 since; the errors 
are kept until the integration restarts",
                 errors.getString("note"));
+        assertEquals("note", errors.keySet().iterator().next(), "the note 
comes before the errors and their stack traces");
+    }
+
+    /** camel_get_log marks the records from before the last reload, with a 
note first in the answer. */
+    @Test
+    void theLogRecordsFromBeforeTheLastReloadAreMarked() {
+        JsonObject log = new JsonObject();
+        JsonArray lines = new JsonArray();
+        for (String time : new String[] { "08:20:00.000", "08:18:09.500", 
"08:10:00.000" }) {
+            JsonObject r = new JsonObject();
+            r.put("time", time);
+            r.put("level", "ERROR");
+            lines.add(r);
+        }
+        log.put("lines", lines);
+        log.put("returnedLines", 3);
+
+        AuthoringTools.markLogBefore(log, "08:18:09.731");
+
+        List<JsonObject> rows = entries(log, "lines");
+        assertFalse(rows.get(0).containsKey("beforeLastReload"), "after the 
reload");
+        assertFalse(rows.get(1).containsKey("beforeLastReload"), "within a 
second of the reload: maybe the new routes");
+        assertEquals(Boolean.TRUE, rows.get(2).get("beforeLastReload"));
+        assertEquals("reloadNote", log.keySet().iterator().next());
+        assertEquals("1 record is from before the routes were last reloaded 
(at 08:18:09.731), so from an earlier version"
+                     + " of the routes (beforeLastReload)",
+                log.getString("reloadNote"));
+
+        // a log that runs past midnight: 23:59:50 is before a reload at 
00:00:30, and 00:00:40 is after it
+        JsonObject night = new JsonObject();
+        JsonArray nightLines = new JsonArray();
+        for (String time : new String[] { "00:00:40.000", "23:59:50.000" }) {
+            JsonObject r = new JsonObject();
+            r.put("time", time);
+            nightLines.add(r);
+        }
+        night.put("lines", nightLines);
+        AuthoringTools.markLogBefore(night, "00:00:30.000");
+        List<JsonObject> nightRows = entries(night, "lines");
+        assertFalse(nightRows.get(0).containsKey("beforeLastReload"));
+        assertEquals(Boolean.TRUE, nightRows.get(1).get("beforeLastReload"));
+
+        // no record before the reload: no note
+        JsonObject fresh = new JsonObject();
+        fresh.put("lines", new JsonArray(List.of(new JsonObject(Map.of("time", 
"08:20:00.000")))));
+        AuthoringTools.markLogBefore(fresh, "08:18:09.731");
+        assertFalse(fresh.containsKey("reloadNote"));
+    }
+
+    @SuppressWarnings("unchecked")
+    private static List<JsonObject> entries(JsonObject answer, String key) {
+        return List.copyOf((java.util.Collection<JsonObject>) answer.get(key));
     }
 
     @Test
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/LogFileReaderTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/LogFileReaderTest.java
index 109a110ed1e8..a6dbbf18b98f 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/LogFileReaderTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/LogFileReaderTest.java
@@ -52,6 +52,25 @@ class LogFileReaderTest {
         assertNull(rows.get(0).get("detail"));
     }
 
+    /**
+     * A thread name with brackets, such as the JMS consumer's 
[onsumer[orders]], is a record of its own: it was folded
+     * into the record before it, so the errors of one route showed as the 
cause of another (r21 of the local-model
+     * benchmark).
+     */
+    @Test
+    void aThreadNameWithBracketsStartsARecord() {
+        List<String> lines = List.of(
+                "2026-10-22 01:25:59.501  INFO 93557 --- [ - file://orders] 
jms-request-reply.camel.yaml:20 : Order ORD-1003 put on the queue",
+                "2026-10-22 01:25:59.530  WARN 93557 --- [onsumer[orders]] 
jms-request-reply.camel.yaml:30 : Reserving ORD-1003 failed");
+        List<JsonObject> rows
+                = List.copyOf(LogFileReader.build(lines, 50, null, null, new 
JsonObject()).getCollection("lines"));
+        assertEquals(2, rows.size());
+        assertEquals("Reserving ORD-1003 failed", 
rows.get(0).getString("message"));
+        assertEquals("WARN", rows.get(0).getString("level"));
+        assertEquals("Order ORD-1003 put on the queue", 
rows.get(1).getString("message"));
+        assertNull(rows.get(1).get("detail"));
+    }
+
     @Test
     void foldsAStormOfTheSameLineIntoOneRecord() {
         List<String> storm = new java.util.ArrayList<>();

Reply via email to