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 c3a01e83609c CAMEL-25868: camel-jbang - AI tools: clearer answers for
cut doc pages, old errors, consumed files and wrong status header names (#28150)
c3a01e83609c is described below
commit c3a01e83609c239cdfad8ab24f7f9b5e3b70069d
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Oct 20 15:40:31 2026 +0200
CAMEL-25868: camel-jbang - AI tools: clearer answers for cut doc pages, old
errors, consumed files and wrong status header names (#28150)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
.../modules/ROOT/pages/camel-jbang-mcp.adoc | 9 +-
.../dsl/jbang/core/commands/ai/AuthoringTools.java | 114 ++++++++++++++++++++-
.../dsl/jbang/core/commands/ai/HeaderChecks.java | 97 +++++++++++++++++-
.../dsl/jbang/core/commands/ai/ReloadOutcome.java | 23 ++++-
.../jbang/core/commands/ai/RepeatedToolCalls.java | 4 +-
.../jbang/core/commands/ai/AuthoringToolsTest.java | 92 +++++++++++++++++
.../jbang/core/commands/ai/ReloadOutcomeTest.java | 19 ++++
.../core/commands/ai/RepeatedToolCallsTest.java | 2 +
.../commands/ai/SourceValidatorEndpointTest.java | 54 ++++++++++
9 files changed, 400 insertions(+), 14 deletions(-)
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 7c0cd31eecbc..ee0f2f80da11 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
@@ -377,7 +377,9 @@ project `directory` as an argument, the runtime tools take
the integration `name
files up front (`routeFiles`, `configFiles`) and, when an integration is
selected, which file and line each of its
routes was loaded from, mapped from the runtime's location (a jar entry of
an exported project, a classpath or
file resource) onto the source under `src/main`. With `file`, a path
relative to the directory as the list names
- it, the content of that file.
+ it, the content of that file. The `.camel` folders are not listed, but
counted (`consumedFiles`): a file consumer
+ moves each file it reads into `.camel` (unless `noop=true`), so a data
folder whose files were all read is not
+ reported as empty.
| `camel_write_file`
| Writes the complete content of a file in the project directory. YAML and
`.properties` content is validated
@@ -441,6 +443,8 @@ project `directory` as an argument, the runtime tools take
the integration `name
| `camel_get_errors`
| The failed exchanges of a running integration: route, exchange, exception
with stack trace, body and headers.
+ The errors are kept until the integration restarts, so those that failed
before the routes were last reloaded are
+ marked with `beforeLastReload`: they come from an earlier version of the
routes.
For an integration that is not running, it says how its previous run ended:
when, the exit code, and the failure.
| `camel_eval_expression`
@@ -490,7 +494,8 @@ stuck doing that until its tool call budget is spent. So
the first two identical
in full, and from the third the answer is a short note instead: that the call
was already answered and how many
times it was asked. The changed answer helps the agent break the loop, and the
context is not filled with copies.
`camel_validate_source` counts the same way when it is given `content` and no
`directory`: the same source gets
-the same answer, and the note also says to change the line the error names.
Validating a file on disk, or
+the same answer, and the note also says to change the line the error names.
For `camel_catalog_doc` the note says
+how to get the sections that a cut page left out: `docPage=<section title>`
returns one section. Validating a file on disk, or
`content` with a `directory` (the checks then read the other files of the
project, which a fix can change), is
always answered in full.
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 6b878fa93f25..551e175bb5c2 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
@@ -25,6 +25,9 @@ import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
@@ -36,6 +39,7 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
+import java.util.TreeMap;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.regex.Matcher;
@@ -118,6 +122,8 @@ public final class AuthoringTools {
.param("camelVersion", "string", VERSION_DESC, false)
.core(true)
.deterministic(true)
+ .repeatHint("When that answer was cut, it named the sections
it left out: docPage=<section title> returns"
+ + " one of them.")
.executor((ctx, args) -> {
applyVersion(ctx, args);
return CatalogDocs.catalogDoc(ctx.catalog(),
args.get("name"), args.get("endpoint"),
@@ -372,7 +378,9 @@ public final class AuthoringTools {
throw notRunning(ctx, args, new
ToolExecutionException("pid " + ctx.pid() + " is not running"));
}
JsonObject errors = ctx.readErrorFile();
- return errors != null ? unescapeBodies(errors).toJson() :
"No errors captured.";
+ return errors != null
+ ? markBeforeLastReload(unescapeBodies(errors),
ctx.pid(), p != null ? p.name() : null).toJson()
+ : "No errors captured.";
}));
registry.accept(tool("camel_eval_expression",
@@ -1845,9 +1853,24 @@ public final class AuthoringTools {
result.put("configFiles", configFiles);
result.put("files", files);
result.put("totalFiles", all.size());
+ // a file consumer moves a file it read into .camel (without
noop=true), which the listing skips
+ Map<String, Integer> consumed = consumedFolders(dir);
+ String moved = null;
+ if (!consumed.isEmpty()) {
+ JsonObject folders = new JsonObject();
+ folders.putAll(consumed);
+ result.put("consumedFiles", folders);
+ moved = String.join(", ", consumed.entrySet().stream().map(e ->
e.getKey() + " holds " + e.getValue()
+ +
(e.getValue() == 1 ? " file" : " files"))
+ .toList())
+ + " that a file consumer moved there after reading
(noop=true leaves the files where they are)";
+ }
if (all.isEmpty()) {
- result.put("message", "The directory has no files");
- } else if (all.size() > MAX_FILES) {
+ result.put("message", moved != null ? "The directory has no files,
but " + moved : "The directory has no files");
+ } else if (moved != null) {
+ result.put("note", moved);
+ }
+ if (all.size() > MAX_FILES) {
result.put("message", "Listing the first " + MAX_FILES + " of " +
all.size()
+ " files; routeFiles and configFiles name
every route and configuration file");
}
@@ -2271,6 +2294,91 @@ public final class AuthoringTools {
}
}
+ /**
+ * Marks the failed exchanges of an earlier version of the routes: those
that failed before the newest reload of the
+ * routes in the log. The errors are kept until the integration restarts,
and an agent that read them after a change
+ * took them for errors of its new version, and changed routes that
already worked.
+ */
+ static JsonObject markBeforeLastReload(JsonObject errors, long pid, String
name) {
+ try {
+ JsonObject log = LogFileReader.read(pid, name, 1,
ReloadOutcome.RELOADED, null);
+ if (log.get("lines") instanceof List<?> lines && !lines.isEmpty()
&& lines.get(0) instanceof JsonObject reload) {
+ return markBefore(errors, reload.getString("time"),
LocalDateTime.now());
+ }
+ } catch (Exception e) {
+ // no log to tell: the errors as they are
+ }
+ return errors;
+ }
+
+ private static final long RELOAD_MARGIN_MILLIS = 1000;
+
+ /** Marks the errors that failed before the reload at the given time of
day; the log keeps the time of day only. */
+ static JsonObject markBefore(JsonObject errors, String reloadTime,
LocalDateTime now) {
+ LocalTime time;
+ try {
+ time = LocalTime.parse(reloadTime);
+ } catch (Exception e) {
+ return errors;
+ }
+ LocalDateTime reload = now.toLocalDate().atTime(time);
+ if (reload.isAfter(now)) {
+ reload = reload.minusDays(1);
+ }
+ // the summary is logged after the new routes started: a margin keeps
a route that fails at once unmarked
+ long reloadMillis =
reload.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() -
RELOAD_MARGIN_MILLIS;
+ int before = 0;
+ if (errors.get("errors") instanceof List<?> entries) {
+ for (Object o : entries) {
+ if (o instanceof JsonObject e && e.get("timestamp") instanceof
Number ts && ts.longValue() < reloadMillis) {
+ e.put("beforeLastReload", true);
+ before++;
+ }
+ }
+ }
+ 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");
+ }
+ return errors;
+ }
+
+ /**
+ * 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.
+ */
+ static Map<String, Integer> consumedFolders(Path dir) {
+ Map<String, Integer> answer = new TreeMap<>();
+ try {
+ Files.walkFileTree(dir, EnumSet.noneOf(FileVisitOption.class),
MAX_DEPTH, new SimpleFileVisitor<>() {
+ @Override
+ public FileVisitResult preVisitDirectory(Path d,
BasicFileAttributes attrs) throws IOException {
+ String name = d.getFileName() != null ?
d.getFileName().toString() : "";
+ if (".camel".equals(name) &&
!d.equals(Path.of(System.getProperty("user.home"), ".camel"))) {
+ try (var files = Files.list(d)) {
+ long count =
files.filter(Files::isRegularFile).count();
+ if (count > 0) {
+
answer.put(dir.relativize(d).toString().replace('\\', '/'), (int) count);
+ }
+ }
+ return FileVisitResult.SKIP_SUBTREE;
+ }
+ return !d.equals(dir) && (SKIPPED_DIRS.contains(name) ||
name.startsWith("."))
+ ? FileVisitResult.SKIP_SUBTREE :
FileVisitResult.CONTINUE;
+ }
+
+ @Override
+ public FileVisitResult visitFileFailed(Path f, IOException e) {
+ return FileVisitResult.CONTINUE;
+ }
+ });
+ } catch (IOException e) {
+ // nothing to add
+ }
+ return answer;
+ }
+
/**
* Camel JSON-escapes the body of a message dump (MessageHelper), so a
JSON body would reach the AI escaped twice (a
* body like {"orderId":1} showed as {\\"orderId\\":1}). The body values
of the errors are unescaped once, as the
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java
index 6fc97eedcc48..47b8bd33f75a 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java
@@ -16,7 +16,9 @@
*/
package org.apache.camel.dsl.jbang.core.commands.ai;
+import java.lang.reflect.Field;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
@@ -26,9 +28,12 @@ import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
+import org.apache.camel.Exchange;
+import org.apache.camel.ExchangeConstantProvider;
import org.apache.camel.catalog.CamelCatalog;
import static
org.apache.camel.dsl.jbang.core.commands.ai.PropertiesChecks.closestName;
+import static
org.apache.camel.dsl.jbang.core.commands.ai.PropertiesChecks.closestNames;
/**
* The Camel* header check of {@link SourceValidator}: a header a component
never sets, against the header metadata of
@@ -42,6 +47,15 @@ final class HeaderChecks {
static final Pattern CAMEL_HEADER_REF_PATTERN = Pattern.compile(
"(?:\\$\\{headers?\\.|headers\\.|headers\\[['\"]|header\\(['\"]|(name):\\s*['\"]?)(Camel[A-Z][A-Za-z0-9]*(?:\\.[A-Za-z0-9_]+)*)");
+ private static final String HTTP_STATUS
+ = "the status code of an HTTP answer is the header
CamelHttpResponseCode (Exchange.HTTP_RESPONSE_CODE)";
+
+ /**
+ * A name written as a constant of Exchange, such as name:
Exchange.HTTP_RESPONSE_CODE: Camel looks the constant up
+ * when it creates the route, for the name of a setHeader, setProperty,
setVariable and of the remove EIPs.
+ */
+ static final Pattern EXCHANGE_CONSTANT_NAME =
Pattern.compile("name:\\s*['\"]?Exchange\\.([A-Z][A-Z0-9_]*)");
+
static final Pattern SCHEME_IN_URI_PATTERN =
Pattern.compile("uri:\\s*\"?([a-zA-Z][a-zA-Z0-9+.-]*):");
/**
@@ -106,6 +120,17 @@ final class HeaderChecks {
+ "
${date:exchangeCreated:yyyy-MM-dd HH:mm:ss}; ${messageTimestamp} is set only by"
+ " components
that stamp their messages."));
+ /**
+ * Camel* names that are not headers of Camel, for a header that has
another name, with what to write: the status
+ * code of an HTTP answer is CamelHttpResponseCode, which models write as
CamelHttpStatusCode. Checked whatever the
+ * components of the file are: the routes of a REST DSL use direct, which
has no header metadata, so the other
+ * checks skip them.
+ */
+ static final Map<String, String> WRONG_NAMES = Map.of(
+ "CamelHttpStatusCode", HTTP_STATUS, "CamelHttpStatus",
HTTP_STATUS, "CamelHttpCode", HTTP_STATUS,
+ "CamelHttpResponseStatus", HTTP_STATUS,
"CamelHttpResponseStatusCode", HTTP_STATUS, "CamelStatusCode",
+ HTTP_STATUS);
+
static String intentHint(String name) {
for (var e : INTENT_HINTS) {
if (e.getKey().matcher(name).find()) {
@@ -139,12 +164,64 @@ final class HeaderChecks {
return msgs;
}
+ /**
+ * The {@link #WRONG_NAMES} the content uses as header names, and the
names written as a constant that Exchange does
+ * not have, which fail the route when Camel creates it; once each, the
names go into reported.
+ */
+ static List<String> wrongNames(String content, Set<String> reported) {
+ List<String> msgs = new ArrayList<>();
+ String[] lines = content.split("\n", -1);
+ for (int i = 0; i < lines.length; i++) {
+ Matcher c = EXCHANGE_CONSTANT_NAME.matcher(lines[i]);
+ if (c.find() && isSetOrRemoveName(lines, i)) {
+ String hint = exchangeConstantHint(c.group(1));
+ if (hint != null && reported.add("Exchange." + c.group(1))) {
+ msgs.add("Line " + (i + 1) + ": " + hint);
+ }
+ }
+ Matcher m = CAMEL_HEADER_REF_PATTERN.matcher(lines[i]);
+ while (m.find()) {
+ if (m.group(1) != null && !isHeaderName(lines, i)) {
+ continue;
+ }
+ String name = m.group(2);
+ String right = WRONG_NAMES.get(name);
+ if (right != null && reported.add(name)) {
+ msgs.add("Line " + (i + 1) + ": Camel has no header " +
name + ": " + right);
+ }
+ }
+ }
+ return msgs;
+ }
+
+ /**
+ * The message for a name written as Exchange.<constant> when Exchange has
no such constant, else null: Camel looks
+ * the constant up, so the name Exchange.HTTP_RESPONSE_CODE of a setHeader
sets CamelHttpResponseCode.
+ */
+ static String exchangeConstantHint(String constant) {
+ if (ExchangeConstantProvider.lookup(constant) != null) {
+ return null;
+ }
+ String start = "Exchange has no constant " + constant + ", so the
route fails to start (Constant field with name: "
+ + constant + " not found on Exchange.class): ";
+ if (constant.contains("STATUS") || constant.contains("RESPONSE")) {
+ return start + HTTP_STATUS;
+ }
+ List<String> fields =
Arrays.stream(Exchange.class.getFields()).map(Field::getName).toList();
+ List<String> closest = closestNames(constant, fields);
+ return start + (closest.isEmpty()
+ ? "write the name of the header, such as CamelFileName"
+ : "did you mean " + String.join(" or ", closest.stream().map(n
-> "Exchange." + n).toList()) + "?");
+ }
+
public static List<String> validateKnownHeaders(String content,
CamelCatalog catalog) {
List<String> msgs = new ArrayList<>();
if (content == null) {
return msgs;
}
msgs.addAll(timerCounterComparisons(content));
+ Set<String> wrong = new HashSet<>();
+ msgs.addAll(wrongNames(content, wrong));
Set<String> known = new LinkedHashSet<>();
Map<String, String> owner = new LinkedHashMap<>();
Matcher sm = SCHEME_IN_URI_PATTERN.matcher(content);
@@ -189,7 +266,7 @@ final class HeaderChecks {
continue;
}
String name = m.group(2);
- if (common.contains(name) || reported.contains(name) ||
isKnown(name, known)) {
+ if (common.contains(name) || reported.contains(name) ||
wrong.contains(name) || isKnown(name, known)) {
continue;
}
String propertyOwner = null;
@@ -301,6 +378,20 @@ final class HeaderChecks {
/** Whether the name: on this line belongs to a setHeader or removeHeader
step, not a setProperty, a bean... */
static boolean isHeaderName(String[] lines, int index) {
+ String step = parentStep(lines, index);
+ return step.startsWith("setHeader:") ||
step.startsWith("removeHeader:");
+ }
+
+ /**
+ * Whether the name: of the line names a header, property or variable,
which Camel parses as a constant of Exchange.
+ */
+ static boolean isSetOrRemoveName(String[] lines, int index) {
+ String step = parentStep(lines, index);
+ return step.startsWith("set") || step.startsWith("remove");
+ }
+
+ /** The step the line belongs to, such as setHeader:, or an empty text. */
+ private static String parentStep(String[] lines, int index) {
int indent = YamlLines.countLeadingSpaces(lines[index]);
for (int j = index - 1; j >= 0; j--) {
String line = lines[j];
@@ -312,10 +403,10 @@ final class HeaderChecks {
if (step.startsWith("- ")) {
step = step.substring(2).trim();
}
- return step.startsWith("setHeader:") ||
step.startsWith("removeHeader:");
+ return step;
}
}
- return false;
+ return "";
}
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcome.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcome.java
index f816c0b87369..cb4f1e38919e 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcome.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcome.java
@@ -178,13 +178,15 @@ public final class ReloadOutcome {
*/
private static final Pattern ROUTE_LOGGER =
Pattern.compile("(^|\\.)(yaml|yml|xml|java|groovy):\\d+$");
private static final int OUTPUT_LINES = 5;
+ private static final int OUTPUT_ERRORS = 3;
private static final int OUTPUT_CHARS = 400;
/**
* What the routes logged after the newest reload, waiting up to the
timeout for the first of it: the records of the
- * routes' log steps, and any WARN or ERROR, newest last, at most five,
each cut to 400 characters. A write answered
- * with the result of the change shows the agent at once what its route
now does (CAMEL-25513): an agent left to
- * call camel_get_log reads it before the reload ran, or not at all.
+ * routes' log steps, and any WARN or ERROR, newest last, each cut to 400
characters: the first five, the first
+ * three ERROR after them, and a line that says how many are left out. A
write answered with the result of the
+ * change shows the agent at once what its route now does (CAMEL-25513):
an agent left to call camel_get_log reads
+ * it before the reload ran, or not at all.
*/
public static JsonArray awaitOutput(long pid, String name, long
timeoutMillis) {
StopWatch watch = new StopWatch();
@@ -226,7 +228,20 @@ public final class ReloadOutcome {
// newest first in the log: the first lines the routes logged after
the reload are at the end
Collections.reverse(lines);
JsonArray out = new JsonArray();
- out.addAll(lines.subList(0, Math.min(OUTPUT_LINES, lines.size())));
+ int first = Math.min(OUTPUT_LINES, lines.size());
+ out.addAll(lines.subList(0, first));
+ // an ERROR after the first lines is kept as well: the route went on,
and its failure is the news
+ int errors = 0;
+ for (String line : lines.subList(first, lines.size())) {
+ if (line.startsWith("ERROR ") && errors < OUTPUT_ERRORS) {
+ out.add(line);
+ errors++;
+ }
+ }
+ int left = lines.size() - out.size();
+ if (left > 0) {
+ out.add("... " + left + " more line" + (left == 1 ? "" : "s") + ":
camel_get_log shows them");
+ }
return out;
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCalls.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCalls.java
index 1be5f79d2564..2b1350d075f2 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCalls.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCalls.java
@@ -77,8 +77,8 @@ public final class RepeatedToolCalls {
note.put("tool", tool.name());
note.put("timesAsked", times);
note.put("note", tool.name() + " was already called with these same
arguments " + (times - 1)
- + " times in this session and answered in full. Its
answer does not change, so it is not sent"
- + " again: it is in your earlier tool result."
+ + " times in this session. Its answer does not
change, so it is not sent again: it is in your"
+ + " earlier tool result."
+ (tool.repeatHint() != null ? " " +
tool.repeatHint() : ""));
return note;
}
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 f684fb684dec..2ca880ea05a0 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
@@ -20,6 +20,8 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -146,6 +148,96 @@ class AuthoringToolsTest {
Map.of("directory", dir.toString(), "file", "missing.yaml")));
}
+ /** A file consumer moves the files it read into .camel, which the listing
skips: the folder looked empty. */
+ @Test
+ void filesAFileConsumerMovedIntoDotCamelAreCounted(@TempDir Path dir)
throws IOException {
+ Files.createDirectories(dir.resolve("customers/.camel"));
+ Files.writeString(dir.resolve("customers/.camel/C-482.json"), "{}");
+ Files.writeString(dir.resolve("customers/.camel/C-483.json"), "{}");
+ JsonObject list = call("camel_get_files", new ToolContext(),
Map.of("directory", dir.toString()));
+ assertEquals(0, list.getInteger("totalFiles"));
+ JsonObject consumed = list.getMap("consumedFiles");
+ assertEquals(1, consumed.size(), consumed.toJson());
+ assertEquals(2, consumed.getInteger("customers/.camel"));
+ assertEquals("The directory has no files, but customers/.camel holds 2
files that a file consumer moved there"
+ + " after reading (noop=true leaves the files where they
are)",
+ list.getString("message"));
+
+ Files.writeString(dir.resolve("demo.camel.yaml"), VALID_ROUTE);
+ list = call("camel_get_files", new ToolContext(), Map.of("directory",
dir.toString()));
+ assertEquals(1, list.getInteger("totalFiles"));
+ assertTrue(list.getString("note").startsWith("customers/.camel holds 2
files"), list.toJson());
+ assertFalse(list.containsKey("message"), list.toJson());
+ }
+
+ @Test
+ void noDotCamelFolderNoNote(@TempDir Path dir) throws IOException {
+ Files.writeString(dir.resolve("demo.camel.yaml"), VALID_ROUTE);
+ Files.createDirectories(dir.resolve("customers/.camel"));
+ JsonObject list = call("camel_get_files", new ToolContext(),
Map.of("directory", dir.toString()));
+ assertFalse(list.containsKey("consumedFiles"), "an empty .camel folder
is not named");
+ assertFalse(list.containsKey("note"), list.toJson());
+ }
+
+ /** The errors are kept until the integration restarts: those from before
the last reload are marked. */
+ @Test
+ void theErrorsFromBeforeTheLastReloadAreMarked() {
+ LocalDateTime now = LocalDateTime.of(2026, 10, 20, 8, 30);
+ JsonObject errors = errorsAt(now.withHour(8).withMinute(10),
LocalDateTime.of(2026, 10, 20, 8, 18, 9, 500_000_000),
+ now.withHour(8).withMinute(20));
+
+ AuthoringTools.markBefore(errors, "08:18:09.731", now);
+
+ List<JsonObject> entries = entries(errors);
+ assertEquals(Boolean.TRUE, entries.get(0).get("beforeLastReload"));
+ 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",
+ errors.getString("note"));
+ }
+
+ @Test
+ void aReloadLaterInTheDayThanNowWasYesterday() {
+ LocalDateTime now = LocalDateTime.of(2026, 10, 20, 0, 5);
+ JsonObject errors = errorsAt(LocalDateTime.of(2026, 10, 19, 23, 50),
LocalDateTime.of(2026, 10, 20, 0, 1));
+
+ AuthoringTools.markBefore(errors, "23:59:00.000", now);
+
+ List<JsonObject> entries = entries(errors);
+ assertEquals(Boolean.TRUE, entries.get(0).get("beforeLastReload"));
+ assertFalse(entries.get(1).containsKey("beforeLastReload"));
+ }
+
+ @Test
+ void noErrorBeforeTheReloadNoNote() {
+ LocalDateTime now = LocalDateTime.of(2026, 10, 20, 8, 30);
+ JsonObject errors = errorsAt(now.withMinute(20));
+ AuthoringTools.markBefore(errors, "08:18:09.731", now);
+ assertFalse(errors.containsKey("note"));
+ assertFalse(entries(errors).get(0).containsKey("beforeLastReload"));
+ // a time the log did not have leaves the errors as they are
+ AuthoringTools.markBefore(errors, "", now);
+ assertFalse(errors.containsKey("note"));
+ }
+
+ private static JsonObject errorsAt(LocalDateTime... times) {
+ JsonArray list = new JsonArray();
+ for (LocalDateTime t : times) {
+ JsonObject e = new JsonObject();
+ e.put("timestamp",
t.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
+ list.add(e);
+ }
+ JsonObject errors = new JsonObject();
+ errors.put("errors", list);
+ return errors;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static List<JsonObject> entries(JsonObject errors) {
+ return (List<JsonObject>) (List<?>) errors.getCollection("errors");
+ }
+
@Test
void writeValidatesFirstAndRefusesInvalidContent(@TempDir Path dir) throws
IOException {
Files.writeString(dir.resolve("demo.camel.yaml"), "- route: {}\n");
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcomeTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcomeTest.java
index 82b97f3e4904..adb97d9222fa 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcomeTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcomeTest.java
@@ -137,6 +137,25 @@ class ReloadOutcomeTest {
assertTrue(output.get(1).toString().startsWith("WARN Rollback file
strategy"), output.toJson());
}
+ /** The errors after the first lines are the news: a route that went on
and failed later on. */
+ @Test
+ void anErrorAfterTheFirstLinesIsKept() {
+ List<String> lines = new java.util.ArrayList<>(RELOADED);
+ for (int i = 1; i <= 6; i++) {
+ lines.add("2026-09-21 10:00:4" + (i + 1) + ".000 INFO 42 --- [
timer://tick] route1 : Order " + i);
+ }
+ lines.add("2026-09-21 10:00:48.500 ERROR 42 --- [ timer://tick]
o.a.c.p.e.DefaultErrorHandler : Failed delivery for"
+ + " exchangeId: A. Exhausted after delivery attempt: 1
caught: java.lang.IllegalStateException: stock");
+ lines.add("2026-09-21 10:00:49.000 INFO 42 --- [ timer://tick] route1
: Order 7");
+
+ JsonArray output = ReloadOutcome.output(records(BEFORE, lines));
+
+ assertEquals(List.of("Hello again", "Order 1", "Order 2", "Order 3",
"Order 4"), List.copyOf(output.subList(0, 5)));
+ assertTrue(output.get(5).toString().startsWith("ERROR Failed delivery
for exchangeId: A"), output.toJson());
+ assertEquals("... 3 more lines: camel_get_log shows them",
output.get(6));
+ assertEquals(7, output.size(), output.toJson());
+ }
+
@Test
void whatWasLoggedBeforeTheNewestReloadIsNotOutput() {
assertEquals(List.of("Hello Camel"),
List.copyOf(ReloadOutcome.output(records(BEFORE))));
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCallsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCallsTest.java
index 2a07467af1e8..c36c47269446 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCallsTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCallsTest.java
@@ -48,6 +48,8 @@ class RepeatedToolCallsTest {
assertThat(note.getString("tool")).isEqualTo("camel_catalog_doc");
assertThat(note.getInteger("timesAsked")).isEqualTo(3);
assertThat(note.getString("note")).contains("2 times");
+ // a page that was cut was not answered in full: the note says how to
get the sections it left out
+ assertThat(note.getString("note")).doesNotContain("in
full").contains("docPage=<section title>");
assertThat(calls.repeatOf(catalogDoc,
sql).getInteger("timesAsked")).isEqualTo(4);
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java
index 17582bf927e6..5ffd282fe596 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java
@@ -533,6 +533,60 @@ class SourceValidatorEndpointTest {
assertThat(msgs).isEmpty();
}
+ /** The routes of a REST DSL use direct, which has no header metadata: a
wrong status header is still reported. */
+ @Test
+ void aWrongHttpStatusHeaderIsReportedInADirectRoute() {
+ List<String> msgs = SourceValidator.validateKnownHeaders("""
+ - rest:
+ get:
+ - path: /orders/{id}
+ to: direct:get-order
+ - route:
+ from:
+ uri: direct:get-order
+ steps:
+ - setHeader:
+ name: CamelHttpStatusCode
+ constant: 404
+ - setHeader:
+ name: Exchange.HTTP_STATUS
+ constant: 404
+ - log: "${header.CamelHttpStatusCode}"
+ """, catalog);
+ assertThat(msgs).hasSize(2);
+ assertThat(msgs.get(0)).startsWith("Line 10:").contains("Camel has no
header CamelHttpStatusCode")
+ .contains("the header CamelHttpResponseCode
(Exchange.HTTP_RESPONSE_CODE)");
+ assertThat(msgs.get(1)).startsWith("Line 13:").contains("Exchange has
no constant HTTP_STATUS")
+ .contains("the route fails to start").contains("the header
CamelHttpResponseCode");
+ }
+
+ @Test
+ void aConstantOfExchangeIsTheNameCamelLooksUp() {
+ // Camel looks the constant up when it creates the route
(DefaultCamelContextExtension.resolvePropertyPlaceholders)
+ List<String> msgs = SourceValidator.validateKnownHeaders("""
+ - from:
+ uri: direct:get-order
+ steps:
+ - setHeader:
+ name: Exchange.HTTP_RESPONSE_CODE
+ constant: 404
+ - setHeader:
+ name: CamelHttpResponseCode
+ constant: 404
+ - setProperty:
+ name: Exchange.HTTP_STATUS
+ constant: 404
+ - setHeader:
+ name: Exchange.FILE_NAM
+ constant: out.json
+ """, catalog);
+ // the name of a setProperty is looked up as well
+ assertThat(msgs).hasSize(2);
+ assertThat(msgs.get(0)).startsWith("Line 11:").contains("Exchange has
no constant HTTP_STATUS");
+ assertThat(msgs.get(1)).startsWith("Line 14:").contains("Exchange has
no constant FILE_NAM")
+ .contains("did you mean Exchange.FILE_NAME");
+ }
+
@Test
void withIncludeMetadataAHeaderLookupFindsTheExchangeProperty() {
// Simple's ${header.x} falls back to the exchange properties, so it
reads the counter