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 353f408c9843 CAMEL-26721: camel-jbang - describe the chaos you want in
your own words, and the AI turns it into checked rules (#29391)
353f408c9843 is described below
commit 353f408c98437d5be4588066b4de78a058c7f087
Author: Claus Ibsen <[email protected]>
AuthorDate: Sat Nov 7 22:11:26 2026 +0100
CAMEL-26721: camel-jbang - describe the chaos you want in your own words,
and the AI turns it into checked rules (#29391)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../ROOT/images/jbang/camel-tui-chaos-describe.png | Bin 0 -> 514742 bytes
.../ROOT/pages/camel-jbang-tui-observe.adoc | 8 +
.../dsl/jbang/core/commands/ai/ToolConfirms.java | 44 +-
.../jbang/core/commands/ai/ToolConfirmsTest.java | 6 +
dsl/camel-jbang/camel-jbang-plugin-tui/README.md | 35 ++
dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml | 7 +
.../camel/dsl/jbang/core/commands/tui/AiPanel.java | 11 +
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 8 +
.../dsl/jbang/core/commands/tui/ChaosAiPrompt.java | 100 ++++
.../dsl/jbang/core/commands/tui/ChaosControl.java | 44 +-
.../jbang/core/commands/tui/MonitorContext.java | 9 +
.../core/commands/tui/TuiToolDefinitions.java | 28 +
.../dsl/jbang/core/commands/tui/TuiToolGroups.java | 17 +-
.../jbang/core/commands/tui/TuiToolRegistry.java | 46 +-
.../src/main/resources/tui/help/chaos.md | 9 +
.../core/commands/tui/AiPanelToolGroupsTest.java | 11 +-
.../jbang/core/commands/tui/ChaosAiPromptTest.java | 102 ++++
.../core/commands/tui/ChaosRequestEvalTest.java | 564 +++++++++++++++++++++
.../core/commands/tui/McpFacadeConfirmTest.java | 26 +
.../jbang/core/commands/tui/SourceSizeTest.java | 2 +-
.../src/test/resources/chaos-eval/requests.json | 277 ++++++++++
21 files changed, 1337 insertions(+), 17 deletions(-)
diff --git
a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos-describe.png
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos-describe.png
new file mode 100644
index 000000000000..9e7c66382c0b
Binary files /dev/null and
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos-describe.png differ
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
index 74d535fe0f63..4587aefcc762 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
@@ -330,6 +330,14 @@ the route, and of its polls when it polls.
image::jbang/camel-tui-chaos-here.png[! on the to callStock of the Diagram:
the known rules for its HTTP endpoint and its step, with the endpoint filled in]
* *x* removes the selected rule, *c* removes all rules (the report keeps what
they did), and *R* starts a new experiment:
no rules and a new report, with the same seed.
+* *d* describes the chaos with AI, when the AI panel is there: say the chaos
you want in your own words, such as "the
+order route fails 5% of the time, or when the country is Spain". Enter sends
it to the AI panel with the routes, the
+sends and the rules of the integration. The AI writes the rules, checks them
with `tui_chaos_check` (each in plain
+words, and what it would hit), shows them, and adds them with `tui_chaos_add`
(or changes a rule with
+`tui_chaos_change`, which keeps its id and gives it a new version), asking you
to confirm each one. It does not change
+the routes. The AI panel has these tools only while chaos is on in the
integration.
++
+image::jbang/camel-tui-chaos-describe.png[Describe with AI: the AI checked the
two rules of "the order route should fail 5% of the time, or when the country
is spain", and asks to add them]
Each asks to confirm with *y*, with the rule shown. A rule that is not valid
comes back with the reason, and a rule that
matches nothing in the integration (often a typo) with a warning; press *e*
there to change it.
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolConfirms.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolConfirms.java
index 855e184a08fe..771b93ebcfd1 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolConfirms.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolConfirms.java
@@ -303,14 +303,56 @@ public final class ToolConfirms {
String where = integration != null && !integration.isBlank() ?
integration : "the selected integration";
String reason = "While the rule is active, the integration's messages
fail, wait or get lost; exception= can name"
+ " any exception class";
+ List<String> rules = c.isEmpty() ? List.of() : splitRules(rule);
return switch (c) {
- case "add" -> new Call("add a chaos rule to " + where, rule,
reason);
+ // several rules separated by ; are added together: one per line,
so each can be read
+ case "add" -> rules.size() > 1
+ ? new Call("add " + rules.size() + " chaos rules to " +
where, String.join("\n", rules), reason)
+ : new Call("add a chaos rule to " + where, rule, reason);
case "change" -> id != null && !id.isBlank()
? new Call("change chaos rule " + id + " of " + where,
rule, reason) : null;
default -> null;
};
}
+ /**
+ * Splits a text with several rules separated by {@code ;}, as in
camel.chaos.rules; a {@code ;} inside quotes does
+ * not split.
+ */
+ static List<String> splitRules(String text) {
+ List<String> answer = new ArrayList<>();
+ StringBuilder current = new StringBuilder();
+ char quote = 0;
+ for (int i = 0; i < text.length(); i++) {
+ char ch = text.charAt(i);
+ if (quote != 0) {
+ current.append(ch);
+ if (ch == '\\' && i + 1 < text.length()) {
+ current.append(text.charAt(++i));
+ } else if (ch == quote) {
+ quote = 0;
+ }
+ } else if (ch == ';') {
+ addRule(answer, current);
+ } else {
+ if (ch == '"' || ch == '\'') {
+ quote = ch;
+ }
+ current.append(ch);
+ }
+ }
+ addRule(answer, current);
+ return answer;
+ }
+
+ private static void addRule(List<String> rules, StringBuilder text) {
+ String rule = text.toString().strip();
+ if (!rule.isEmpty()) {
+ rules.add(rule);
+ }
+ text.setLength(0);
+ }
+
/** Running an integration on this machine. */
public static Call run(String what) {
return new Call("run an integration", what, "It starts an integration
on this machine with the project's code");
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolConfirmsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolConfirmsTest.java
index 63e11ca544ca..f868d9b950e4 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolConfirmsTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolConfirmsTest.java
@@ -292,6 +292,12 @@ class ToolConfirmsTest {
assertThat(add.target()).isEqualTo("endpoint=* error
exception=java.sql.SQLException");
assertThat(add.reason()).contains("fail, wait or get lost",
"exception=");
+ // several rules are added together: one per line, and a ; inside
quotes does not separate
+ ToolConfirms.Call two = ToolConfirms.chaos("add", "chaosdemo", null,
+ "route=orders error rate=0.05; route=orders error
when=\"${header.note} == 'a;b'\"");
+ assertThat(two.title()).isEqualTo("add 2 chaos rules to chaosdemo");
+ assertThat(two.target()).isEqualTo("route=orders error
rate=0.05\nroute=orders error when=\"${header.note} == 'a;b'\"");
+
ToolConfirms.Call change = ToolConfirms.chaos("change", null, "r1",
"endpoint=* drop");
assertThat(change.title()).isEqualTo("change chaos rule r1 of the
selected integration");
assertThat(change.target()).isEqualTo("endpoint=* drop");
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/README.md
b/dsl/camel-jbang/camel-jbang-plugin-tui/README.md
index 2d357e021f5f..846e15b5324b 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/README.md
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/README.md
@@ -79,5 +79,40 @@ How to read it:
request takes about a minute. Mixture-of-experts models such as
`qwen3.6:35b-a3b` are several times
faster, which is why the TUI recommends them.
+### `ChaosRequestEvalTest` (opt-in)
+
+The eval set of "describe the chaos you want" (`d` on the Chaos screen): 18
loose requests in
+`src/test/resources/chaos-eval/requests.json`, such as "the order route should
fail 5% of the time, or
+when the country is spain", each with the rules it should become. Each request
goes to a local model as
+the AI panel sends it (its system prompt, the core tools and the chaos tools,
and the question of
+`ChaosAiPrompt.ofRequest`). camel-chaos answers `tui_chaos_check` over routes
shaped like the chaos
+demo; the rules the model adds or changes are recorded, as if the user
confirmed them, and scored: each
+expected rule must match one rule (its target, fault and options as regular
expressions, `null` for an
+option that must be absent, the texts of its `when=`), and no rule may be left
over. It is skipped unless
+`CAMEL_TUI_CHAOS_EVAL` names the model:
+
+```bash
+CAMEL_TUI_CHAOS_EVAL=qwen3.6:35b-mlx mvn test -Dtest=ChaosRequestEvalTest
+```
+
+`CAMEL_TUI_CHAOS_EVAL_RUNS=3` runs each request three times,
`CAMEL_TUI_CHAOS_EVAL_ONLY=<id>` runs only
+the requests whose id contains it, and `CAMEL_TUI_CHAOS_EVAL_VERBOSE=true`
prints each tool call. It
+measures a model and the prompt; it does not fail on a wrong answer. A line
per request and a summary:
+
+```
+FAIL fail-or-spain 12.0s 2 calls 1 checks 14177 tok rate=0.05,
expected none in [route=orders error rate=0.05]
+PASS stock-503 13.0s 3 calls 1 checks 28456 tok
[endpoint=http://localhost:8765/stock error status=503 rate=0.2]
+...
+7 of 18 passed (39%), 11.1s and 4 tool calls on average
+```
+
+With `qwen3.6:35b-mlx` (Apple M4 Pro, 2026-11-07) single runs pass 6 or 7 of
18; one run of the same
+prompt can differ from the next by several requests, so compare a change of
the prompt over three runs.
+The known rules of the catalog in the question took it from 3 to 6: timeouts,
stalled routes, the polls
+of a consumer and outages then come out right. What it still gets wrong: the
same fault at two places
+(the service and the route that calls it), a value of the examples
(`rate=0.05`) in rules that did not
+ask for one, `drop` for "cut in half" (`corrupt mode=truncate`), and a check
repeated with the same
+mistake.
+
The same figures are available live inside the TUI: `/context` in the F8 panel
shows the prefix and
history size of the next request, and `/usage` the tokens and latency spent so
far.
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml
b/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml
index ca0f2b248143..e2c0f2e01cd4 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml
@@ -115,6 +115,13 @@
<type>test-jar</type>
<scope>test</scope>
</dependency>
+ <dependency>
+ <!-- the eval of the chaos requests answers the check tool with
camel-chaos itself -->
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-chaos</artifactId>
+ <version>${project.version}</version>
+ <scope>test</scope>
+ </dependency>
<dependency>
<groupId>org.apache.camel</groupId>
<artifactId>camel-test-junit6</artifactId>
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 036db0262b07..f3f75f40c949 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
@@ -598,6 +598,17 @@ class AiPanel {
inputLine.replace(question);
}
+ /**
+ * Opens the panel and sends the question, which the user typed and
confirmed on a screen. While the panel answers
+ * another question, or asks about an edit, the question waits in the
input for the user to send.
+ */
+ void askNow(String question) {
+ openWithQuestion(question);
+ if (!thinking.get() && editQuestionHandler == null && activeCliCommand
== null) {
+ submitInput();
+ }
+ }
+
boolean isAskingAboutEdit() {
return editQuestionHandler != null;
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
index dc41edae958f..d53884bacde7 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
@@ -601,6 +601,14 @@ public class CamelMonitor extends CamelCommand {
public void ask(String question) {
openAiWithQuestion(dir -> question);
}
+
+ @Override
+ public void askNow(String question) {
+ if (shellPanel.isOpen()) {
+ shellPanel.close();
+ }
+ aiPanel.askNow(question);
+ }
};
ctx.projectOverviewCallback = () -> {
if (shellPanel.isOpen()) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosAiPrompt.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosAiPrompt.java
index 552ee7baeaaf..b9a0fb741d0a 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosAiPrompt.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosAiPrompt.java
@@ -48,6 +48,106 @@ final class ChaosAiPrompt {
return sb.toString();
}
+ /** The most routes and sends quoted in a request, so a big integration
does not flood it. */
+ static final int MAX_LISTED = 20;
+
+ /**
+ * The question for a request in the user's own words ({@code d} on the
Chaos screen, CAMEL-26721), such as "the
+ * order route should fail 5% of the time": the request, the routes and
the sends of the integration and its rules
+ * now, the rule syntax in short, and what to do - write the rules, check
them with tui_chaos_check (which says what
+ * each would hit, in plain words), show them, and add them only with
tui_chaos_add (or change one with
+ * tui_chaos_change), which ask the user first.
+ */
+ static String ofRequest(IntegrationInfo info, String request) {
+ return ofRequest(info, request, null);
+ }
+
+ /**
+ * The question for a request in the user's own words, with the known
rules of camel-chaos (the recipes of the chaos
+ * model, a line each with its rule), which show the forms a short syntax
leaves out: a timeout, a stalled route,
+ * the polls of a consumer, the dead letter channel.
+ *
+ * @param recipes the known rules, or null when the catalog has none
+ */
+ static String ofRequest(IntegrationInfo info, String request, ChaosRecipes
recipes) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("Turn my request into camel-chaos rules for the running
integration ").append(name(info))
+ .append(" (development only, faults injected on purpose). My
request: ").append(request.strip())
+ .append('\n');
+ int listed = 0;
+ StringBuilder routes = new StringBuilder();
+ for (RouteInfo route : info.routes) {
+ if (route.routeId != null && listed++ < MAX_LISTED) {
+ routes.append(" ").append(route.routeId);
+ if (route.from != null) {
+ routes.append(" from ").append(route.from);
+ }
+ routes.append('\n');
+ }
+ }
+ if (!routes.isEmpty()) {
+ sb.append("Its routes:\n").append(routes);
+ }
+ // the endpoint a route consumes is not one of its sends (a send to it
from another route is)
+ List<String> consumed = new ArrayList<>();
+ info.routes.forEach(r -> {
+ if (r.from != null && r.routeId != null) {
+ consumed.add(r.routeId + " " + sameUri(r.from));
+ }
+ });
+ List<String> sends = new ArrayList<>();
+ for (EndpointInfo endpoint : info.endpoints) {
+ if ("out".equals(endpoint.direction) && endpoint.uri != null &&
sends.size() < MAX_LISTED
+ && !consumed.contains(endpoint.routeId + " " +
sameUri(endpoint.uri))) {
+ String send = withoutQuery(endpoint.uri) + (endpoint.routeId
!= null ? " (from " + endpoint.routeId + ")" : "");
+ if (!sends.contains(send)) {
+ sends.add(send);
+ }
+ }
+ }
+ if (!sends.isEmpty()) {
+ sb.append("It sends to:\n");
+ sends.forEach(s -> sb.append(" ").append(s).append('\n'));
+ }
+ ChaosInfo chaos = info.chaos;
+ if (chaos != null && !chaos.rules.isEmpty()) {
+ sb.append("Its chaos rules now, which are active already (add only
new rules; change one of these with ")
+ .append(TuiToolGroups.CHAOS_CHANGE_TOOL).append(" and its
id):\n");
+ chaos.rules.forEach(r -> sb.append(" ").append(r.id).append(":
").append(r.text).append('\n'));
+ }
+ sb.append("A rule is one line: <target> <fault> [<parameter>=<value>
...] [<option>=<value> ...]. Targets:")
+ .append(" endpoint=<uri pattern>, component=<scheme>,
route=<route id>, step=<step id>, with * for any")
+ .append(" characters. Faults: error (exception=<class>,
message=, status=<http code> for http),")
+ .append(" delay (duration=500ms or 100ms..2s), drop, corrupt
(mode=headers|charset|truncate|empty),")
+ .append(" duplicate, stall (route=, duration=). Options:
rate=0.05 (or every:N), until=10m,")
+ .append(" in=<route id> for the sends and steps of a route,
when=\"${header.country} == 'ES'\" for")
+ .append(" the messages that meet a condition (simple
language). route=<id> error fails 5% of the")
+ .append(" exchanges of a route with rate=0.05; \"or\" is two
rules. Each rule has its own rate: without")
+ .append(" rate= a rule fails every message it matches.\n");
+ if (recipes != null && !recipes.recipes().isEmpty()) {
+ sb.append("Known rules, with ${...} to fill in:\n");
+ recipes.recipes().forEach(r -> sb.append("
").append(r.title()).append(": ").append(r.rule()).append('\n'));
+ }
+ sb.append("The routes and sends above are all you need: do not look
around with other tools. First call ")
+ .append(TuiToolGroups.CHAOS_CHECK_TOOL).append(" with the
rules (several separated by ;): it says each")
+ .append(" rule in plain words and what it would hit, or why it
is not valid. Fix the rules until it")
+ .append(" accepts them and they hit what I asked for. Then
show me each rule with its plain words and")
+ .append(" what it hits, and add them with
").append(TuiToolGroups.CHAOS_ADD_TOOL)
+ .append(" (or change a rule above with
").append(TuiToolGroups.CHAOS_CHANGE_TOOL)
+ .append("), which asks me to confirm first. Do not change the
routes.");
+ return sb.toString();
+ }
+
+ // a URI without its query and the // after its scheme, to tell the
endpoint a route consumes
+ private static String sameUri(String uri) {
+ return withoutQuery(uri).replaceFirst(":/+", ":");
+ }
+
+ private static String withoutQuery(String uri) {
+ int query = uri.indexOf('?');
+ return query > 0 ? uri.substring(0, query) : uri;
+ }
+
/** The question for an error a chaos rule made, on the Errors tab: it is
not a bug to fix. */
static String ofError(IntegrationInfo info, ChaosInfo.Rule rule, String
ruleId, ErrorInfo error) {
StringBuilder sb = new StringBuilder();
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosControl.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosControl.java
index 502211cb0e5b..b28382b46a26 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosControl.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosControl.java
@@ -65,7 +65,10 @@ final class ChaosControl {
CHANGE,
REMOVE,
CLEAR,
- RESET
+ RESET,
+ // the chaos the user wants, in their own words, for the AI panel to
turn into rules (not sent to the
+ // integration)
+ DESCRIBE
}
private final MonitorContext ctx;
@@ -87,6 +90,8 @@ final class ChaosControl {
private String resultTitle;
private final List<String> resultLines = new ArrayList<>();
private boolean resultError;
+ // the integration the user describes the chaos for
+ private IntegrationInfo describing;
ChaosControl(MonitorContext ctx) {
this(ctx, null, ChaosRecipes::load);
@@ -200,6 +205,12 @@ final class ChaosControl {
ask(Command.RESET);
return true;
}
+ if (ke.isChar('d') && ctx.askAiCallback != null) {
+ // the chaos the user wants in their own words, which the AI turns
into rules, checks and adds (CAMEL-26721)
+ describing = info;
+ openInput(Command.DESCRIBE, "");
+ return true;
+ }
return false;
}
@@ -260,7 +271,13 @@ final class ChaosControl {
close();
} else if (ke.isConfirm()) {
String text = input.text().strip();
- if (!text.isEmpty()) {
+ if (!text.isEmpty() && command == Command.DESCRIBE) {
+ // the AI panel asks right away: the user typed the request
and pressed Enter; the rules it makes are
+ // added only through the add tool, which asks the user first
+ IntegrationInfo info = describing;
+ close();
+ ctx.askAiCallback.askNow(ChaosAiPrompt.ofRequest(info, text,
knownRules()));
+ } else if (!text.isEmpty()) {
rule = text;
ask(command);
}
@@ -310,6 +327,7 @@ final class ChaosControl {
step = Step.NONE;
input = null;
form = null;
+ describing = null;
}
/** The request of the chaos action for the command, as {@code camel cmd
chaos-*} sends it. */
@@ -376,6 +394,8 @@ final class ChaosControl {
case REMOVE -> "Removed chaos rule " + id;
case CLEAR -> "Removed all chaos rules";
case RESET -> "New chaos experiment: no rules, a new report";
+ // never sent to the integration
+ case DESCRIBE -> "";
};
if (reply.get("warnings") instanceof List<?> warnings &&
!warnings.isEmpty()) {
List<String> lines = new ArrayList<>();
@@ -424,9 +444,16 @@ final class ChaosControl {
void render(Frame frame, Rect area) {
switch (step) {
case RECIPES -> form.render(frame, area);
- case INPUT -> DialogHelper.renderInputDialog(frame, area,
- command == Command.CHANGE ? "Change chaos rule " + id :
"Add a chaos rule to " + name, input,
- "endpoint=kafka:orders* error rate=0.1 until=5m");
+ case INPUT -> {
+ if (command == Command.DESCRIBE) {
+ DialogHelper.renderInputDialog(frame, area, "Describe the
chaos you want in " + name, input,
+ "the order route fails 5% of the time, or when the
country is Spain");
+ } else {
+ DialogHelper.renderInputDialog(frame, area,
+ command == Command.CHANGE ? "Change chaos rule " +
id : "Add a chaos rule to " + name, input,
+ "endpoint=kafka:orders* error rate=0.1 until=5m");
+ }
+ }
case CONFIRM -> DialogHelper.renderConfirm(frame, area, title(),
question(), detail(), "y", "yes", null, null);
case RESULT -> renderResult(frame, area);
default -> {
@@ -442,6 +469,7 @@ final class ChaosControl {
case REMOVE -> "Remove Chaos Rule";
case CLEAR -> "Clear Chaos Rules";
case RESET -> "New Chaos Experiment";
+ case DESCRIBE -> "Describe the Chaos";
};
}
@@ -453,6 +481,7 @@ final class ChaosControl {
case REMOVE -> "Remove chaos rule " + id + " from " + name + "?";
case CLEAR -> "Remove all chaos rules from " + name + "? The
report keeps what they did";
case RESET -> "Start a new chaos experiment in " + name + "? No
rules, and a new report (same seed)";
+ case DESCRIBE -> "Ask the AI to turn this into chaos rules?";
};
}
@@ -501,7 +530,7 @@ final class ChaosControl {
switch (step) {
case RECIPES -> form.renderFooter(spans);
case INPUT -> {
- TuiHelper.hint(spans, "Enter", "confirm");
+ TuiHelper.hint(spans, "Enter", command == Command.DESCRIBE ?
"ask the AI" : "confirm");
TuiHelper.hint(spans, "Esc", "cancel");
}
case CONFIRM -> {
@@ -518,6 +547,9 @@ final class ChaosControl {
TuiHelper.hint(spans, "c", "clear");
}
TuiHelper.hint(spans, "R", "new experiment");
+ if (ctx.askAiCallback != null) {
+ TuiHelper.hint(spans, "d", "describe with AI");
+ }
}
}
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
index e993f61030b6..82fe1c37e685 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
@@ -407,5 +407,14 @@ class MonitorContext {
*/
default void ask(String question) {
}
+
+ /**
+ * Opens the AI panel and sends the question at once, for a request
the user typed and confirmed on a screen
+ * ({@code d} on the Chaos screen). When the panel is busy, the
question waits in its input, as with
+ * {@link #ask(String)}.
+ */
+ default void askNow(String question) {
+ ask(question);
+ }
}
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
index 2e06525cf032..129b830242c2 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
@@ -50,6 +50,7 @@ final class TuiToolDefinitions {
List<TuiTool> tools = new ArrayList<>();
addInteractionTools(tools, r);
addStructuredDataTools(tools, r);
+ addChaosTools(tools, r);
addDiagramTools(tools, r);
addSharedTools(tools, r);
addCatalogTools(tools, r);
@@ -294,6 +295,33 @@ final class TuiToolDefinitions {
r::callDiagramZoom));
}
+ /**
+ * The chaos rules of the selected integration (camel-chaos), which only
its tool group adds when chaos is on: check
+ * a rule, and add or change one after the user confirms (CAMEL-26721).
+ */
+ private static void addChaosTools(List<TuiTool> tools, TuiToolRegistry r) {
+ tools.add(tool(toolDef(
+ TuiToolGroups.CHAOS_CHECK_TOOL,
+ "Checks chaos rules without adding them: each rule in plain
words, where it can fire, warnings, and "
+ + "what it would hit now
(hits: sends, polls, routes or steps with "
+ + "their route); a rule that
is not valid comes back with the reason.",
+ Map.of("rule", propDef("string", "One-line rule, or several
separated by ;")),
+ List.of("rule")), r::callChaosCheck, READ_ONLY));
+ tools.add(tool(toolDef(
+ TuiToolGroups.CHAOS_ADD_TOOL,
+ "Adds chaos rules to the selected integration after the user
confirms; returns their ids and plain "
+ + "words. Check them with " +
TuiToolGroups.CHAOS_CHECK_TOOL + " first.",
+ Map.of("rule", propDef("string", "One-line rule, or several
separated by ;")),
+ List.of("rule")), r::callChaosAdd));
+ tools.add(tool(toolDef(
+ TuiToolGroups.CHAOS_CHANGE_TOOL,
+ "Changes a chaos rule after the user confirms: the rule with
the id gets the new rule and its next "
+ + "version; the report keeps
the old one.",
+ Map.of("id", propDef("string", "The id of the rule, such as
r1"),
+ "rule", propDef("string", "The new one-line rule")),
+ List.of("id", "rule")), r::callChaosChange));
+ }
+
private static void addStructuredDataTools(List<TuiTool> tools,
TuiToolRegistry r) {
tools.add(tool(toolDef(
"tui_get_table",
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolGroups.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolGroups.java
index 070491a70878..523dbc905645 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolGroups.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolGroups.java
@@ -37,6 +37,9 @@ final class TuiToolGroups {
static final String UPDATE_ROW_TOOL = "tui_update_row";
static final String HTTP_ENDPOINTS_TOOL = "tui_http_endpoints";
static final String HTTP_REQUEST_TOOL = "tui_http_request";
+ static final String CHAOS_CHECK_TOOL = "tui_chaos_check";
+ static final String CHAOS_ADD_TOOL = "tui_chaos_add";
+ static final String CHAOS_CHANGE_TOOL = "tui_chaos_change";
/**
* The groups of an integration as the panel uses them.
@@ -78,6 +81,11 @@ final class TuiToolGroups {
if (f.semanticMonitoring()) {
tools.add("tui_get_monitoring");
}
+ } else if (group == ToolGroup.CHAOS) {
+ // with chaos off the rules can only be refused
+ if (f.chaosOn()) {
+ tools.addAll(tools(group));
+ }
} else {
tools.addAll(tools(group));
}
@@ -91,6 +99,7 @@ final class TuiToolGroups {
case SQL -> List.of(SQL_TOOL, UPDATE_ROW_TOOL);
case HTTP -> List.of(HTTP_ENDPOINTS_TOOL, HTTP_REQUEST_TOOL);
case SEMANTIC -> List.of("tui_get_audit", "tui_get_monitoring");
+ case CHAOS -> List.of(CHAOS_CHECK_TOOL, CHAOS_ADD_TOOL,
CHAOS_CHANGE_TOOL);
default -> List.of();
};
}
@@ -119,11 +128,13 @@ final class TuiToolGroups {
+ " rate; OPEN means the fallback runs.";
case HTTP -> "HTTP: " + ToolGroups.describeHttp(f) + "; " +
HTTP_REQUEST_TOOL + " calls it, "
+ HTTP_ENDPOINTS_TOOL + " lists the operations.";
- // the panel has no chaos tools (camel ask and camel-jbang-mcp
have them): it only learns that faults may be
- // injected on purpose, so it does not hunt for a bug behind them
+ // faults may be injected on purpose, so the panel does not hunt
for a bug behind them; it checks, adds and
+ // changes rules as camel ask does, adding and changing after the
user confirms (CAMEL-26721)
case CHAOS -> f.chaosOn()
? "Chaos: camel-chaos is on, so some failures and delays
may be injected on purpose; tui_get_status"
- + " section 'chaos' has the rules, tui_get_table tab
'Chaos' their live state and effect."
+ + " section 'chaos' has the rules, tui_get_table tab
'Chaos' their live state and effect; "
+ + CHAOS_CHECK_TOOL + " checks rules, " + CHAOS_ADD_TOOL
+ " and " + CHAOS_CHANGE_TOOL
+ + " add and change them after the user confirms."
: "Chaos: camel-chaos is in the integration but off, so it
injects no faults.";
};
}
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 63d187fea689..1d6d3f929bd4 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
@@ -259,10 +259,14 @@ class TuiToolRegistry {
return tools;
}
+ // the tools that only the tool groups of the integration add, also to the
full set: the semantic history, and
+ // the chaos rules when camel-chaos is on
+ private static final Set<String> GROUP_ONLY_TOOLS =
Set.of("tui_get_audit", "tui_get_monitoring",
+ TuiToolGroups.CHAOS_CHECK_TOOL, TuiToolGroups.CHAOS_ADD_TOOL,
TuiToolGroups.CHAOS_CHANGE_TOOL);
+
List<ToolDef> getAvailableToolDefinitions(Collection<String> selected) {
return getToolDefinitions().stream()
- .filter(tool -> !Set.of("tui_get_audit",
"tui_get_monitoring").contains(tool.name())
- || selected.contains(tool.name()))
+ .filter(tool -> !GROUP_ONLY_TOOLS.contains(tool.name()) ||
selected.contains(tool.name()))
.toList();
}
@@ -1341,6 +1345,44 @@ class TuiToolRegistry {
return Jsoner.serialize(response);
}
+ /** Checks chaos rules in the selected integration (chaos_check), which
adds nothing. */
+ String callChaosCheck(Map<String, Object> args) {
+ String rule = args.get("rule") instanceof String s ? s : null;
+ if (rule == null || rule.isBlank()) {
+ return "Error: rule is required";
+ }
+ return executeShared("chaos_check", Map.of("rule", rule));
+ }
+
+ /** Adds chaos rules to the selected integration (chaos_add), after the
user confirms. */
+ String callChaosAdd(Map<String, Object> args) {
+ String rule = args.get("rule") instanceof String s ? s : null;
+ if (rule == null || rule.isBlank()) {
+ return "Error: rule is required";
+ }
+ JsonObject rejected = facade != null
+ ? facade.confirm(ToolConfirms.chaos("add",
facade.getSelectedIntegrationName(), null, rule)) : null;
+ if (rejected != null) {
+ return Jsoner.serialize(rejected);
+ }
+ return executeShared("chaos_add", Map.of("rule", rule));
+ }
+
+ /** Changes a chaos rule of the selected integration (chaos_change), after
the user confirms. */
+ String callChaosChange(Map<String, Object> args) {
+ String id = args.get("id") instanceof String s ? s : null;
+ String rule = args.get("rule") instanceof String s ? s : null;
+ if (id == null || id.isBlank() || rule == null || rule.isBlank()) {
+ return "Error: id and rule are required";
+ }
+ JsonObject rejected = facade != null
+ ? facade.confirm(ToolConfirms.chaos("change",
facade.getSelectedIntegrationName(), id, rule)) : null;
+ if (rejected != null) {
+ return Jsoner.serialize(rejected);
+ }
+ return executeShared("chaos_change", Map.of("id", id, "rule", rule));
+ }
+
String callUpdateRow(Map<String, Object> args) {
String table = (String) args.get("table");
if (table == null || table.isBlank()) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/chaos.md
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/chaos.md
index bc110d1593df..96e4daa87e73 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/chaos.md
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/chaos.md
@@ -77,6 +77,15 @@ error handler (HAND) and the redeliveries (REDEL).
says what the fault simulates and how the routes coped, and suggests how
to make them cope better, changing the routes only when you agree.
Press Enter to send the question, or change it first
+- **d** — Describe with AI: say the chaos you want in your own words, such
+ as "the order route fails 5% of the time, or when the country is Spain".
+ Enter sends it to the AI panel with the routes, the sends and the rules
+ of the integration. The AI writes the rules, checks them with
+ `tui_chaos_check` (each rule in plain words, and what it would hit),
+ shows them to you, and adds them with `tui_chaos_add` (or changes a rule
+ with `tui_chaos_change`, which keeps its id and gives it a new version),
+ asking you to confirm each one. It does not change the routes. The AI
+ panel has these tools only while chaos is on in the integration
Each asks to confirm with **y**, showing the rule. The integration checks
the rule as `camel.chaos.rules` is checked: a rule that is not valid comes
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelToolGroupsTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelToolGroupsTest.java
index 666fe4879c02..663c0165c508 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelToolGroupsTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelToolGroupsTest.java
@@ -236,19 +236,22 @@ class AiPanelToolGroupsTest {
}
@Test
- void theChaosGroupAddsOnlyALine() {
- // the panel has no chaos tools; its model learns that faults may be
injected on purpose
+ void theChaosGroupAddsTheRuleToolsWhenChaosIsOn() {
+ // its model learns that faults may be injected on purpose, and
checks, adds and changes rules (CAMEL-26721)
TuiToolGroups.Selection on = TuiToolGroups.select(
AppFeatures.fromStatus(new JsonObject(Map.of("chaos",
Map.of("enabled", true)))));
assertEquals(List.of(ToolGroup.CHAOS), on.groups());
- assertTrue(on.tools().isEmpty(), on.tools().toString());
+ assertEquals(List.of("tui_chaos_check", "tui_chaos_add",
"tui_chaos_change"), on.tools());
assertEquals(List.of("Chaos: camel-chaos is on, so some failures and
delays may be injected on purpose;"
+ " tui_get_status section 'chaos' has the rules,
tui_get_table tab 'Chaos' their live"
- + " state and effect."),
+ + " state and effect; tui_chaos_check checks
rules, tui_chaos_add and tui_chaos_change add"
+ + " and change them after the user confirms."),
on.guidance());
+ // with chaos off a rule can only be refused: no tools
TuiToolGroups.Selection off = TuiToolGroups.select(
AppFeatures.fromStatus(new JsonObject(Map.of("chaos",
Map.of("enabled", false)))));
+ assertTrue(off.tools().isEmpty(), off.tools().toString());
assertEquals(List.of("Chaos: camel-chaos is in the integration but
off, so it injects no faults."),
off.guidance());
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosAiPromptTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosAiPromptTest.java
index b3f5a7f5c355..c2b6f36dbe9d 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosAiPromptTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosAiPromptTest.java
@@ -78,9 +78,111 @@ class ChaosAiPromptTest {
public void ask(String question) {
asked.add(question);
}
+
+ @Override
+ public void askNow(String question) {
+ asked.add("now: " + question);
+ }
};
}
+ @Test
+ void aRequestInTheUsersWordsWithTheRoutesTheSendsAndTheRules() {
+ RouteInfo orders = new RouteInfo();
+ orders.routeId = "orders";
+ orders.from = "timer://orders?period=500";
+ info.routes.add(orders);
+ EndpointInfo stock = new EndpointInfo();
+ stock.uri = "http://localhost:8765/stock?bridgeEndpoint=true";
+ stock.direction = "out";
+ stock.routeId = "orders";
+ info.endpoints.add(stock);
+ EndpointInfo in = new EndpointInfo();
+ in.uri = "timer://orders";
+ in.direction = "in";
+ info.endpoints.add(in);
+ // a route that serves HTTP: its own endpoint is not a send, a send to
it from orders is
+ RouteInfo stockRoute = new RouteInfo();
+ stockRoute.routeId = "stock";
+ stockRoute.from = "platform-http:/stock";
+ info.routes.add(stockRoute);
+ EndpointInfo served = new EndpointInfo();
+ served.uri = "platform-http:///stock";
+ served.direction = "out";
+ served.routeId = "stock";
+ info.endpoints.add(served);
+ EndpointInfo direct = new EndpointInfo();
+ direct.uri = "direct://inventory";
+ direct.direction = "out";
+ direct.routeId = "orders";
+ info.endpoints.add(direct);
+
+ String question = ChaosAiPrompt.ofRequest(info, " the order route
fails 5% of the time, or for Spain ");
+
+ assertThat(question).startsWith("Turn my request into camel-chaos
rules for the running integration chaosdemo"
+ + " (development only, faults injected
on purpose). My request: the order route"
+ + " fails 5% of the time, or for
Spain\n")
+ .contains("Its routes:\n orders from
timer://orders?period=500\n")
+ // the sends, without their query; not the consumers
+ .contains("It sends to:\n http://localhost:8765/stock (from
orders)\n direct://inventory (from orders)\n")
+ .doesNotContain("platform-http:///stock (from stock)")
+ .doesNotContain("bridgeEndpoint").doesNotContain("
timer://orders (from")
+ .contains("Its chaos rules now, which are active already (add
only new rules; change one of these with"
+ + " tui_chaos_change and its id):\n r1:
endpoint=http://localhost:8765/stock* error status=503")
+ .contains("in=<route id>").contains("when=\"${header.country}
== 'ES'\"").contains("\"or\" is two rules")
+ .contains("Each rule has its own rate: without rate= a rule
fails every message it matches.")
+ .contains("do not look around with other
tools").contains("First call tui_chaos_check with the rules")
+ .endsWith("add them with tui_chaos_add (or change a rule above
with tui_chaos_change), which asks me to"
+ + " confirm first. Do not change the routes.");
+ }
+
+ @Test
+ void theKnownRulesShowTheFormsTheShortSyntaxLeavesOut() {
+ ChaosRecipes recipes = ChaosRecipes.parse(ChaosRecipesTest.MODEL);
+ String question = ChaosAiPrompt.ofRequest(info, "calls to stock time
out", recipes);
+ assertThat(question).contains("Known rules, with ${...} to fill
in:\n");
+ recipes.recipes().forEach(r -> assertThat(question).contains(" " +
r.title() + ": " + r.rule() + "\n"));
+ // without a catalog that has them, none
+ assertThat(ChaosAiPrompt.ofRequest(info, "calls to stock time
out")).doesNotContain("Known rules");
+ }
+
+ @Test
+ void dOnTheChaosScreenAsksTheAiWithTheRequest() {
+ ChaosTab tab = new ChaosTab(ctx);
+ TuiTestHelper.renderToString(tab, 160, 40);
+ List<Span> footer = new ArrayList<>();
+ tab.renderFooter(footer);
+ assertThat(text(footer)).contains("describe with AI");
+
+ tab.handleKeyEvent(KeyEvent.ofChar('d', KeyModifiers.NONE));
+ assertThat(tab.isTextInputActive()).isTrue();
+ assertThat(TuiTestHelper.renderToString(tab, 160,
40)).contains("Describe the chaos you want in chaosdemo");
+ // Enter on an empty request does nothing
+ tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+ assertThat(asked).isEmpty();
+ for (char ch : "shipping is slow".toCharArray()) {
+ tab.handleKeyEvent(KeyEvent.ofChar(ch, KeyModifiers.NONE));
+ }
+ tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+
+ // asked at once, as the user typed it and pressed Enter; nothing goes
to the integration
+ assertThat(asked).singleElement().asString().startsWith("now: Turn my
request into camel-chaos rules")
+ .contains("My request: shipping is slow");
+ assertThat(tab.control().isOpen()).isFalse();
+ }
+
+ @Test
+ void withoutTheAiThereIsNoDescribe() {
+ ctx.askAiCallback = null;
+ ChaosTab tab = new ChaosTab(ctx);
+ TuiTestHelper.renderToString(tab, 160, 40);
+ tab.handleKeyEvent(KeyEvent.ofChar('d', KeyModifiers.NONE));
+ assertThat(tab.control().isOpen()).isFalse();
+ List<Span> footer = new ArrayList<>();
+ tab.renderFooter(footer);
+ assertThat(text(footer)).doesNotContain("describe");
+ }
+
@Test
void theQuestionHasTheRuleItsEffectAndTheRoutesItHit() {
String question = ChaosAiPrompt.of(info, info.chaos.rule("r1"));
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRequestEvalTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRequestEvalTest.java
new file mode 100644
index 000000000000..c27e1f4dd4ed
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRequestEvalTest.java
@@ -0,0 +1,564 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.io.InputStream;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Properties;
+import java.util.regex.Pattern;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Consumer;
+import org.apache.camel.Endpoint;
+import org.apache.camel.EndpointAware;
+import org.apache.camel.Exchange;
+import org.apache.camel.Processor;
+import org.apache.camel.Producer;
+import org.apache.camel.Route;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.chaos.ChaosManager;
+import org.apache.camel.dsl.jbang.core.commands.LlmClient;
+import org.apache.camel.dsl.jbang.core.commands.ai.AppFeatures;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.DefaultComponent;
+import org.apache.camel.support.DefaultEndpoint;
+import org.apache.camel.support.DefaultProducer;
+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 static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+/**
+ * The eval set of the "describe the chaos you want" input of the Chaos screen
(CAMEL-26721, S2-5 of CAMEL-26607): loose
+ * requests ({@code chaos-eval/requests.json}), each sent to a local model as
the AI panel sends it (its system prompt,
+ * the core tools and the chaos tools, and the question of {@link
ChaosAiPrompt#ofRequest}). The check tool is answered
+ * by camel-chaos itself, over routes shaped like the chaos demo; the rules
the model adds or changes are recorded, as
+ * if the user confirmed them, and scored against what the request means.
Skipped unless {@code CAMEL_TUI_CHAOS_EVAL}
+ * names the model, for example:
+ *
+ * <pre>
+ * CAMEL_TUI_CHAOS_EVAL=qwen3.6:35b-mlx mvn test -Dtest=ChaosRequestEvalTest
+ * </pre>
+ *
+ * {@code CAMEL_TUI_CHAOS_EVAL_RUNS} runs each request more than once (default
1), {@code CAMEL_TUI_CHAOS_EVAL_ONLY}
+ * runs only the requests whose id contains it, {@code
CAMEL_TUI_CHAOS_EVAL_VERBOSE=true} prints each tool call, and
+ * {@code OLLAMA_HOST} overrides the server (default {@code
http://localhost:11434}). It prints a line per request and a
+ * summary, and does not fail on a wrong answer: it measures a model.
+ */
+class ChaosRequestEvalTest {
+
+ // the most turns of a conversation before it counts as stuck
+ private static final int MAX_TURNS = 8;
+ private static final String YES = "yes, add them";
+ // the known rules of the catalog, as the Chaos screen loads them
+ private static final ChaosRecipes RECIPES = ChaosRecipes.load();
+ private static final boolean VERBOSE =
Boolean.parseBoolean(System.getenv("CAMEL_TUI_CHAOS_EVAL_VERBOSE"));
+
+ /** A rule the model added (change null), or the new rule of a rule it
changed. */
+ record Proposed(String change, String rule) {
+ }
+
+ /** How one request went. */
+ record Outcome(String id, String failure, List<Proposed> proposed, int
toolCalls, int checks, double seconds,
+ long tokens) {
+
+ boolean passed() {
+ return failure == null;
+ }
+ }
+
+ // ---- the scoring, which always runs ----
+
+ @Test
+ void theScoringReadsTheRulesAsTheParserDoes() throws Exception {
+ JsonObject spain = (JsonObject) Jsoner.deserialize("""
+ {"target": "route=orders", "fault": ["error"], "rate": null,
"when": ["country", "ES"]}""");
+ assertThat(mismatch(spain, new Proposed(null, "route=orders error
when=\"${header.country} == 'ES'\""))).isNull();
+ assertThat(mismatch(spain, new Proposed(null, "route=orders error
when='${header.country} == \"ES\"'"))).isNull();
+ assertThat(mismatch(spain, new Proposed(null, "route=orders error
rate=0.05 when=\"${header.country} == 'ES'\"")))
+ .isEqualTo("rate=0.05, expected none");
+ assertThat(mismatch(spain, new Proposed(null, "route=orders
error"))).isEqualTo("no when= with country, ES");
+ // rate=1 fires at every match, as no rate does
+ assertThat(mismatch(spain, new Proposed(null, "route=orders error
rate=1 when=\"${header.country} == 'ES'\"")))
+ .isNull();
+ assertThat(mismatch(spain, new Proposed(null, "route=shipping error
when=\"${header.country} == 'ES'\"")))
+ .isEqualTo("target route=shipping, expected route=orders");
+
+ JsonObject slow = (JsonObject) Jsoner.deserialize("""
+ {"target": "endpoint=http.*pay.*", "fault": ["delay"],
"duration": "(1s|1000ms)\\\\.\\\\.(3s|3000ms)"}""");
+ assertThat(mismatch(slow, new Proposed(null,
"endpoint=http://localhost:8765/pay delay duration=1s..3s"))).isNull();
+ assertThat(mismatch(slow, new Proposed(null, "endpoint=http:*pay*
error duration=1s..3s")))
+ .isEqualTo("fault error, expected [delay]");
+
+ JsonObject change = (JsonObject) Jsoner.deserialize("""
+ {"change": "r1", "target": "endpoint=http.*pay.*", "fault":
["error"], "rate": "0\\\\.10?"}""");
+ assertThat(mismatch(change, new Proposed("r1", "endpoint=http:*pay*
error rate=0.1"))).isNull();
+ assertThat(mismatch(change, new Proposed(null, "endpoint=http:*pay*
error rate=0.1")))
+ .isEqualTo("added, expected a change of r1");
+
+ // every expected rule matches one rule, and no rule is left over
+ List<JsonObject> two = List.of(spain, (JsonObject)
Jsoner.deserialize("""
+ {"target": "route=orders", "fault": ["error"], "rate":
"0\\\\.05"}"""));
+ List<Proposed> both = List.of(new Proposed(null, "route=orders error
rate=0.05"),
+ new Proposed(null, "route=orders error
when=\"${header.country} == 'ES'\""));
+ assertThat(score(two, both)).isNull();
+ assertThat(score(two, both.subList(0, 1))).startsWith("2 rules
expected, 1 added or changed");
+ assertThat(score(two, List.of())).isEqualTo("no rule added or
changed");
+ }
+
+ /** Why the rule is not the expected one, or null when it is. */
+ static String mismatch(JsonObject expect, Proposed proposed) {
+ String change = expect.getString("change");
+ if (change != null && !change.equals(proposed.change())) {
+ return proposed.change() == null ? "added, expected a change of "
+ change : "changed " + proposed.change();
+ }
+ if (change == null && proposed.change() != null) {
+ return "changed " + proposed.change() + ", expected an added rule";
+ }
+ Map<String, String> rule = parse(proposed.rule());
+ String target = rule.get("@target");
+ if (target == null || !Pattern.matches(expect.getString("target"),
target)) {
+ return "target " + target + ", expected " +
expect.getString("target");
+ }
+ List<Object> faults = expect.getCollection("fault");
+ if (!faults.contains(rule.get("@fault"))) {
+ return "fault " + rule.get("@fault") + ", expected " + faults;
+ }
+ for (Map.Entry<String, Object> e : expect.entrySet()) {
+ String key = e.getKey();
+ if (List.of("change", "target", "fault", "when").contains(key)) {
+ continue;
+ }
+ String value = rule.get(key);
+ if ("rate".equals(key) && value != null && List.of("1", "1.0",
"every:1").contains(value)) {
+ // fires at every match, as without a rate
+ value = null;
+ }
+ if (e.getValue() == null) {
+ if (value != null) {
+ return key + "=" + value + ", expected none";
+ }
+ } else if (value == null || !Pattern.matches((String)
e.getValue(), value)) {
+ return key + "=" + value + ", expected " + e.getValue();
+ }
+ }
+ List<Object> when = expect.getCollection("when");
+ if (when != null) {
+ String condition = rule.get("when");
+ if (condition == null || !when.stream().allMatch(w ->
condition.contains((String) w))) {
+ return (condition == null ? "no when=" : "when=" + condition +
" without") + " with "
+ + String.join(", ",
when.stream().map(String::valueOf).toList());
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Whether the rules are the expected ones: each expected rule matches its
own rule, and none is left over. Null
+ * when they are, else why not.
+ */
+ static String score(List<JsonObject> expected, List<Proposed> proposed) {
+ if (proposed.isEmpty()) {
+ return "no rule added or changed";
+ }
+ if (expected.size() != proposed.size()) {
+ return expected.size() + " rules expected, " + proposed.size() + "
added or changed: "
+ + proposed.stream().map(Proposed::rule).toList();
+ }
+ List<Proposed> left = new ArrayList<>(proposed);
+ for (JsonObject expect : expected) {
+ String why = null;
+ Proposed match = null;
+ for (Proposed p : left) {
+ String m = mismatch(expect, p);
+ if (m == null) {
+ match = p;
+ break;
+ }
+ why = why == null ? m + " in [" + p.rule() + "]" : why;
+ }
+ if (match == null) {
+ return why;
+ }
+ left.remove(match);
+ }
+ return null;
+ }
+
+ /** The tokens of a rule (quote aware, the quotes left out), by name; the
target as @target, the fault as @fault. */
+ static Map<String, String> parse(String rule) {
+ Map<String, String> answer = new LinkedHashMap<>();
+ StringBuilder current = new StringBuilder();
+ char quote = 0;
+ List<String> tokens = new ArrayList<>();
+ for (int i = 0; i < rule.length(); i++) {
+ char ch = rule.charAt(i);
+ if (quote != 0) {
+ if (ch == '\\' && i + 1 < rule.length()) {
+ current.append(rule.charAt(++i));
+ } else if (ch == quote) {
+ quote = 0;
+ } else {
+ current.append(ch);
+ }
+ } else if (ch == '"' || ch == '\'') {
+ quote = ch;
+ } else if (ch == ' ' || ch == '\t') {
+ if (!current.isEmpty()) {
+ tokens.add(current.toString());
+ current.setLength(0);
+ }
+ } else {
+ current.append(ch);
+ }
+ }
+ if (!current.isEmpty()) {
+ tokens.add(current.toString());
+ }
+ for (String token : tokens) {
+ int eq = token.indexOf('=');
+ if (eq < 0) {
+ answer.put("@fault", token);
+ continue;
+ }
+ String key = token.substring(0, eq);
+ if (List.of("endpoint", "component", "route",
"step").contains(key)) {
+ answer.put("@target", token);
+ } else {
+ answer.put(key, token.substring(eq + 1));
+ }
+ }
+ return answer;
+ }
+
+ // ---- the eval, against a local model ----
+
+ @Test
+ void evalAgainstALocalModel() throws Exception {
+ String model = System.getenv("CAMEL_TUI_CHAOS_EVAL");
+ assumeTrue(model != null && !model.isBlank(), "set
CAMEL_TUI_CHAOS_EVAL=<model> to run the eval");
+ String host = System.getenv("OLLAMA_HOST");
+ String url = host != null && !host.isBlank() ? host :
"http://localhost:11434";
+ url = url.startsWith("http") ? url : "http://" + url;
+ int runs =
Integer.parseInt(System.getenv().getOrDefault("CAMEL_TUI_CHAOS_EVAL_RUNS",
"1"));
+ String only = System.getenv("CAMEL_TUI_CHAOS_EVAL_ONLY");
+
+ JsonObject set;
+ try (InputStream is =
getClass().getResourceAsStream("/chaos-eval/requests.json")) {
+ set = (JsonObject) Jsoner.deserialize(new
String(is.readAllBytes(), StandardCharsets.UTF_8));
+ }
+ HttpClient http =
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
+ try (CamelContext context = chaosDemo()) {
+ ChaosManager chaos = ChaosManager.get(context);
+
+ AiPanelToolGroupsTest.FakeApp app = new
AiPanelToolGroupsTest.FakeApp();
+ app.features = AppFeatures.fromStatus(new
JsonObject(Map.of("chaos", Map.of("enabled", true))));
+ AiPanel panel =
AiPanelToolGroupsTest.panel(AiPanel.TOOL_MODE_CORE, app);
+ panel.refreshToolGroupsForTesting();
+ String system = panel.systemPromptForTesting();
+ JsonArray tools = wireTools(panel.toolDefinitionsForTesting());
+
+ System.out.println("Chaos request eval with " + model + " at " +
url + ", " + tools.size() + " tools, "
+ + runs + " run(s)");
+ List<Outcome> outcomes = new ArrayList<>();
+ for (Object o : set.getCollection("requests")) {
+ JsonObject request = (JsonObject) o;
+ String id = request.getString("id");
+ if (only != null && !only.isBlank() && !id.contains(only)) {
+ continue;
+ }
+ List<Object> expect = request.getCollection("expect");
+ List<JsonObject> expected =
expect.stream().map(JsonObject.class::cast).toList();
+ // the rules the integration has when the request is made:
only those of the request
+ chaos.reset();
+ JsonArray existing = new JsonArray();
+ List<Object> rules = request.getCollection("existingRules");
+ for (Object r : rules != null ? rules : List.of()) {
+ String rule = ((JsonObject) r).getString("rule");
+ String ruleId = chaos.addRule(rule);
+ existing.add(new JsonObject(Map.of("id", ruleId,
"version", 1, "state", "active", "rule", rule)));
+ }
+ IntegrationInfo info = integration(context, existing);
+ for (int run = 0; run < runs; run++) {
+ Outcome outcome = converse(http, url, model, system,
tools, chaos, info, id,
+ request.getString("request"), expected);
+ outcomes.add(outcome);
+ System.out.println(line(outcome));
+ }
+ }
+ long passed = outcomes.stream().filter(Outcome::passed).count();
+ System.out.printf(Locale.ROOT, "%d of %d passed (%.0f%%), %.1fs
and %d tool calls on average%n", passed,
+ outcomes.size(), 100.0 * passed / Math.max(1,
outcomes.size()),
+
outcomes.stream().mapToDouble(Outcome::seconds).average().orElse(0),
+
Math.round(outcomes.stream().mapToInt(Outcome::toolCalls).average().orElse(0)));
+ assertThat(outcomes).isNotEmpty();
+ }
+ }
+
+ private static String line(Outcome o) {
+ return String.format(Locale.ROOT, "%-4s %-24s %5.1fs %2d calls %2d
checks %6d tok %s",
+ o.passed() ? "PASS" : "FAIL", o.id(), o.seconds(),
o.toolCalls(), o.checks(), o.tokens(),
+ o.passed()
+ ? o.proposed().stream().map(p -> (p.change() != null ?
p.change() + " -> " : "") + p.rule()).toList()
+ : o.failure());
+ }
+
+ /** One request, as a conversation: the tool calls are answered until the
model adds or changes rules. */
+ private static Outcome converse(
+ HttpClient http, String url, String model, String system,
JsonArray tools, ChaosManager chaos,
+ IntegrationInfo info, String id, String request, List<JsonObject>
expected)
+ throws Exception {
+ JsonArray messages = new JsonArray();
+ messages.add(message("system", system));
+ messages.add(message("user",
+ "[Monitoring chaosdemo (PID 4711)]\n" +
ChaosAiPrompt.ofRequest(info, request, RECIPES)));
+ List<Proposed> proposed = new ArrayList<>();
+ int calls = 0;
+ int checks = 0;
+ long tokens = 0;
+ boolean saidYes = false;
+ long start = System.nanoTime();
+ for (int turn = 0; turn < MAX_TURNS && proposed.isEmpty(); turn++) {
+ JsonObject reply = chat(http, url, model, messages, tools);
+ tokens += reply.getLongOrDefault("prompt_eval_count", 0L) +
reply.getLongOrDefault("eval_count", 0L);
+ JsonObject msg = (JsonObject) reply.get("message");
+ messages.add(msg);
+ List<Object> toolCalls = msg.getCollection("tool_calls");
+ if (toolCalls == null || toolCalls.isEmpty()) {
+ if (saidYes) {
+ break;
+ }
+ // it asks before adding, as in the live try: say yes once
+ messages.add(message("user", YES));
+ saidYes = true;
+ continue;
+ }
+ for (Object tc : toolCalls) {
+ JsonObject function = (JsonObject) ((JsonObject)
tc).get("function");
+ String name = function.getString("name");
+ Object rawArgs = function.get("arguments");
+ JsonObject args = rawArgs instanceof JsonObject jo
+ ? jo : (JsonObject)
Jsoner.deserialize(String.valueOf(rawArgs));
+ calls++;
+ String result;
+ switch (name) {
+ case TuiToolGroups.CHAOS_CHECK_TOOL -> {
+ checks++;
+ result = check(chaos, args.getString("rule"));
+ }
+ case TuiToolGroups.CHAOS_ADD_TOOL -> {
+ for (String rule : splitRules(args.getString("rule")))
{
+ proposed.add(new Proposed(null, rule));
+ }
+ result = "{\"added\": true, \"note\": \"the user
confirmed\"}";
+ }
+ case TuiToolGroups.CHAOS_CHANGE_TOOL -> {
+ proposed.add(new Proposed(args.getString("id"),
args.getString("rule")));
+ result = "{\"changed\": true, \"note\": \"the user
confirmed\"}";
+ }
+ default -> result = "{\"error\": \"" + name + " is not
available here: use the chaos tools\"}";
+ }
+ if (VERBOSE) {
+ System.out.println(" " + id + " " + name + " " +
args.toJson() + " -> "
+ + (result.length() > 300 ?
result.substring(0, 300) + "..." : result));
+ }
+ JsonObject toolMessage = message("tool", result);
+ toolMessage.put("tool_name", name);
+ messages.add(toolMessage);
+ }
+ }
+ double seconds = (System.nanoTime() - start) / 1e9;
+ return new Outcome(id, score(expected, proposed), proposed, calls,
checks, seconds, tokens);
+ }
+
+ private static String check(ChaosManager chaos, String rule) {
+ if (rule == null || rule.isBlank()) {
+ return "{\"error\": \"rule is required\"}";
+ }
+ try {
+ return Jsoner.serialize(chaos.checkRules(rule));
+ } catch (IllegalArgumentException e) {
+ return "{\"error\": " + Jsoner.serialize(e.getMessage()) + "}";
+ }
+ }
+
+ // the rules of a text with several separated by ; (a ; in quotes does not
separate)
+ private static List<String> splitRules(String text) {
+ List<String> answer = new ArrayList<>();
+ if (text == null) {
+ return answer;
+ }
+ StringBuilder current = new StringBuilder();
+ char quote = 0;
+ for (char ch : text.toCharArray()) {
+ if (quote != 0) {
+ quote = ch == quote ? 0 : quote;
+ current.append(ch);
+ } else if (ch == ';') {
+ answer.add(current.toString().strip());
+ current.setLength(0);
+ } else {
+ quote = ch == '"' || ch == '\'' ? ch : 0;
+ current.append(ch);
+ }
+ }
+ answer.add(current.toString().strip());
+ answer.removeIf(String::isEmpty);
+ return answer;
+ }
+
+ private static JsonObject chat(HttpClient http, String url, String model,
JsonArray messages, JsonArray tools)
+ throws Exception {
+ JsonObject options = new JsonObject();
+ options.put("temperature", 0.3);
+ options.put("num_ctx", 32768);
+ JsonObject request = new JsonObject();
+ request.put("model", model);
+ request.put("messages", messages);
+ request.put("tools", tools);
+ request.put("stream", false);
+ request.put("think", false);
+ request.put("keep_alive", "30m");
+ request.put("options", options);
+ HttpResponse<String> response = http.send(
+ HttpRequest.newBuilder(URI.create(url + "/api/chat"))
+ .timeout(Duration.ofMinutes(10))
+ .header("Content-Type", "application/json")
+
.POST(HttpRequest.BodyPublishers.ofString(request.toJson(),
StandardCharsets.UTF_8))
+ .build(),
+ HttpResponse.BodyHandlers.ofString());
+ return (JsonObject) Jsoner.deserialize(response.body());
+ }
+
+ private static JsonObject message(String role, String content) {
+ JsonObject m = new JsonObject();
+ m.put("role", role);
+ m.put("content", content);
+ return m;
+ }
+
+ private static JsonArray wireTools(List<LlmClient.ToolDef> defs) {
+ JsonArray tools = new JsonArray();
+ for (LlmClient.ToolDef def : defs) {
+ JsonObject function = new JsonObject();
+ function.put("name", def.name());
+ function.put("description", def.description());
+ function.put("parameters", def.parameters());
+ JsonObject tool = new JsonObject();
+ tool.put("type", "function");
+ tool.put("function", function);
+ tools.add(tool);
+ }
+ return tools;
+ }
+
+ // ---- the routes of the chaos demo, with camel-chaos on ----
+
+ private static CamelContext chaosDemo() throws Exception {
+ CamelContext context = new DefaultCamelContext();
+ Properties props = new Properties();
+ props.put("camel.chaos.enabled", "true");
+ props.put("camel.chaos.seed", "42");
+ context.getPropertiesComponent().setOverrideProperties(props);
+ // the HTTP services, which the routes call but never reach: the
routes do not run (the timer waits)
+ context.addComponent("http", new SilentComponent());
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+
errorHandler(deadLetterChannel("seda:dlq").maximumRedeliveries(2).redeliveryDelay(200));
+ from("timer:orders?period=500&delay=3600000").routeId("orders")
+ .setHeader("country", constant("DK")).id("pickCountry")
+ .to("direct:inventory").id("callInventory")
+ .to("http://localhost:8765/stock").id("callStock")
+ .to("direct:payment").id("callPayment")
+ .to("seda:shipping").id("toShipping");
+ from("direct:inventory").routeId("inventory")
+ .setHeader("reserved", constant(true)).id("reserve")
+ .log("reserved ${body}").id("logReserved");
+ from("direct:payment").routeId("payment")
+ .to("http://localhost:8765/pay").id("callPay");
+ from("seda:shipping").routeId("shipping")
+ .log("shipping ${header.orderId}").id("logShipping");
+
from("file:target/chaos-eval/inbox?delay=3600000&initialDelay=3600000").routeId("returns")
+ .log("return ${header.CamelFileName}").id("logReturn");
+ from("seda:dlq").routeId("dlq").log("DLQ ${body}");
+ }
+ });
+ context.start();
+ return context;
+ }
+
+ /** The integration as the TUI sees it: its routes, the endpoints each
sends to, and its chaos rules. */
+ private static IntegrationInfo integration(CamelContext context, JsonArray
rules) {
+ IntegrationInfo info = new IntegrationInfo();
+ info.pid = "4711";
+ info.name = "chaosdemo";
+ for (Route route : context.getRoutes()) {
+ RouteInfo r = new RouteInfo();
+ r.routeId = route.getRouteId();
+ r.from = route.getEndpoint().getEndpointUri();
+ info.routes.add(r);
+ for (Processor p : route.filter("*")) {
+ if (p instanceof EndpointAware aware && aware.getEndpoint() !=
null) {
+ EndpointInfo e = new EndpointInfo();
+ e.uri = aware.getEndpoint().getEndpointUri();
+ e.direction = "out";
+ e.routeId = route.getRouteId();
+ info.endpoints.add(e);
+ }
+ }
+ }
+ info.chaos = ChaosInfo.parse(new JsonObject(Map.of("enabled", true,
"rules", rules)));
+ return info;
+ }
+
+ /** An HTTP component that answers nothing: the eval only needs its
endpoints. */
+ static final class SilentComponent extends DefaultComponent {
+ @Override
+ protected Endpoint createEndpoint(String uri, String remaining,
Map<String, Object> parameters) {
+ return new DefaultEndpoint(uri, this) {
+ @Override
+ public Producer createProducer() {
+ return new DefaultProducer(this) {
+ @Override
+ public void process(Exchange exchange) {
+ // no answer
+ }
+ };
+ }
+
+ @Override
+ public Consumer createConsumer(Processor processor) {
+ throw new UnsupportedOperationException("the eval has no
HTTP server");
+ }
+ };
+ }
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeConfirmTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeConfirmTest.java
index 9a3c5573681d..2c7630f4faf5 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeConfirmTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeConfirmTest.java
@@ -117,6 +117,32 @@ class McpFacadeConfirmTest {
.containsExactly("run SQL that is not a SELECT", "update a row
of orders");
}
+ @Test
+ void addingAndChangingAChaosRuleAskFirstAndCheckingDoesNot() throws
Exception {
+ AnsweringBridge bridge = new AnsweringBridge(false);
+ TuiToolRegistry registry = new TuiToolRegistry(facade(context(),
bridge));
+
+ JsonObject added = (JsonObject)
Jsoner.deserialize(registry.execute("tui_chaos_add",
+ new JsonObject(Map.of("rule", "route=orders error
rate=0.05"))));
+ JsonObject changed = (JsonObject)
Jsoner.deserialize(registry.execute("tui_chaos_change",
+ new JsonObject(Map.of("id", "r1", "rule", "route=orders error
rate=0.1"))));
+
+ // not confirmed: nothing went to the integration
+ assertThat(added.getString("status")).isEqualTo("rejected");
+ assertThat(changed.getString("status")).isEqualTo("rejected");
+ assertThat(bridge.calls).extracting(ToolConfirms.Call::target)
+ .containsExactly("route=orders error rate=0.05", "route=orders
error rate=0.1");
+ assertThat(registry.execute("tui_chaos_add", new
JsonObject(Map.of()))).isEqualTo("Error: rule is required");
+
+ // the check only reads: it is not asked (here no integration answers
it)
+ try {
+ registry.execute("tui_chaos_check", new JsonObject(Map.of("rule",
"route=orders error")));
+ } catch (RuntimeException e) {
+ // no integration with pid 1 runs in the test
+ }
+ assertThat(bridge.calls).hasSize(2);
+ }
+
@Test
void groovyAsksFirstThroughTheTool() throws Exception {
AnsweringBridge bridge = new AnsweringBridge(false);
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java
index 0b0b7534ec34..fe7d84bb4d54 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java
@@ -59,7 +59,7 @@ class SourceSizeTest {
AiPanel.java 3200
AiPanel.runAgentLoop 159
AiUsageStats.render 166
- CamelMonitor.java 2624
+ CamelMonitor.java 2632
DiagramSupport.java 2360
DiagramSupport.loadHighlightedNativeDiagramInBackground 200
EndpointsTab.renderContent 170
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/resources/chaos-eval/requests.json
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/resources/chaos-eval/requests.json
new file mode 100644
index 000000000000..a88508cca117
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/resources/chaos-eval/requests.json
@@ -0,0 +1,277 @@
+{
+ "description": "Loose requests for chaos rules, against the routes of the
chaos demo (orders calls inventory, the stock service over http, payment and
shipping; payment calls the pay service over http; returns polls a folder;
seda:dlq is the dead letter channel). Each expected rule matches one rule the
model adds: target is a regular expression of the target token, the other keys
a regular expression of the option or parameter value (null: it must be
absent), the fault one of a list, a [...]
+ "requests": [
+ {
+ "id": "fail-or-spain",
+ "request": "the order route should fail 5% of the time, or when the
country is spain",
+ "expect": [
+ {
+ "target": "route=orders",
+ "fault": [
+ "error"
+ ],
+ "rate": "0\\.05"
+ },
+ {
+ "target": "route=orders",
+ "fault": [
+ "error"
+ ],
+ "rate": null,
+ "when": [
+ "country",
+ "ES"
+ ]
+ }
+ ]
+ },
+ {
+ "id": "stock-503",
+ "request": "make the stock service return 503 for a fifth of the calls",
+ "expect": [
+ {
+ "target": "endpoint=http.*stock.*",
+ "fault": [
+ "error"
+ ],
+ "status": "503",
+ "rate": "0\\.20?"
+ }
+ ]
+ },
+ {
+ "id": "payment-slow",
+ "request": "the payment service is slow, between 1 and 3 seconds",
+ "expect": [
+ {
+ "target": "endpoint=http.*pay.*",
+ "fault": [
+ "delay"
+ ],
+ "duration": "(1s|1000ms)\\.\\.(3s|3000ms)"
+ }
+ ]
+ },
+ {
+ "id": "payment-timeout",
+ "request": "calls to the payment service time out after 5 seconds",
+ "expect": [
+ {
+ "target": "endpoint=http.*pay.*",
+ "fault": [
+ "error"
+ ],
+ "after": "5s|5000ms|5000",
+ "exception": ".*Timeout.*"
+ }
+ ]
+ },
+ {
+ "id": "lost-shipments",
+ "request": "every 10th shipment is lost",
+ "expect": [
+ {
+ "target": "endpoint=seda:(//)?shipping.*|component=seda",
+ "fault": [
+ "drop"
+ ],
+ "rate": "every:10"
+ }
+ ]
+ },
+ {
+ "id": "duplicate-shipments",
+ "request": "send each shipment twice, to see if shipping copes with
duplicates",
+ "expect": [
+ {
+ "target": "endpoint=seda:(//)?shipping.*",
+ "fault": [
+ "duplicate"
+ ],
+ "rate": null
+ }
+ ]
+ },
+ {
+ "id": "spain-not-shipped",
+ "request": "orders from Spain should never reach shipping",
+ "expect": [
+ {
+ "target": "endpoint=seda:(//)?shipping.*",
+ "fault": [
+ "drop"
+ ],
+ "rate": null,
+ "when": [
+ "country",
+ "ES"
+ ]
+ }
+ ]
+ },
+ {
+ "id": "returns-stall",
+ "request": "pause the returns route for 30 seconds",
+ "expect": [
+ {
+ "target": "route=returns",
+ "fault": [
+ "stall",
+ "lifecycle"
+ ],
+ "duration": "30s|30000ms"
+ }
+ ]
+ },
+ {
+ "id": "returns-polls",
+ "request": "half of the polls of the returns folder fail",
+ "expect": [
+ {
+ "target": "endpoint=file.*inbox.*|component=file",
+ "fault": [
+ "error"
+ ],
+ "at": "poll",
+ "rate": "0\\.50?"
+ }
+ ]
+ },
+ {
+ "id": "dlq-down",
+ "request": "the dead letter queue is down",
+ "expect": [
+ {
+ "target": "endpoint=seda:(//)?dlq.*",
+ "fault": [
+ "error"
+ ],
+ "at": "dlq"
+ }
+ ]
+ },
+ {
+ "id": "reserve-bug",
+ "request": "the step that reserves the stock in the inventory route
throws an IllegalStateException now and then, say 10%",
+ "expect": [
+ {
+ "target": "step=reserve",
+ "fault": [
+ "error"
+ ],
+ "exception": "(java\\.lang\\.)?IllegalStateException",
+ "rate": "0\\.10?"
+ }
+ ]
+ },
+ {
+ "id": "orders-http-500",
+ "request": "every http call the orders route makes fails with a 500, for
the next 2 minutes",
+ "expect": [
+ {
+ "target": "endpoint=http.*|component=http",
+ "fault": [
+ "error"
+ ],
+ "status": "500",
+ "until": "2m|120s",
+ "in": "orders"
+ }
+ ]
+ },
+ {
+ "id": "truncate-shipping",
+ "request": "cut the messages sent to shipping in half, a quarter of the
time",
+ "expect": [
+ {
+ "target": "endpoint=seda:(//)?shipping.*",
+ "fault": [
+ "corrupt"
+ ],
+ "mode": "truncate",
+ "rate": "0\\.25"
+ }
+ ]
+ },
+ {
+ "id": "stock-outage",
+ "request": "the stock service is down for 10 seconds every minute",
+ "expect": [
+ {
+ "target": "endpoint=http.*stock.*",
+ "fault": [
+ "error"
+ ],
+ "rate": "burst:(10s|10000ms)/(1m|60s)"
+ }
+ ]
+ },
+ {
+ "id": "warm-up",
+ "request": "after a 30 second warm-up, fail 5% of the orders",
+ "expect": [
+ {
+ "target": "route=orders",
+ "fault": [
+ "error"
+ ],
+ "rate": "0\\.05",
+ "from": "\\+?(30s|30000ms)"
+ }
+ ]
+ },
+ {
+ "id": "fewer-payment-failures",
+ "request": "the payment failures are too many, make it 10%",
+ "expect": [
+ {
+ "change": "r1",
+ "target": "endpoint=http.*pay.*",
+ "fault": [
+ "error"
+ ],
+ "status": "503",
+ "rate": "0\\.10?"
+ }
+ ],
+ "existingRules": [
+ {
+ "id": "r1",
+ "rule": "endpoint=http://localhost:8765/pay error status=503
rate=0.5"
+ }
+ ]
+ },
+ {
+ "id": "spain-payments",
+ "request": "payments of Spanish orders fail with a 503",
+ "expect": [
+ {
+ "target": "endpoint=http.*pay.*",
+ "fault": [
+ "error"
+ ],
+ "status": "503",
+ "rate": null,
+ "when": [
+ "country",
+ "ES"
+ ]
+ }
+ ]
+ },
+ {
+ "id": "payment-route-slow",
+ "request": "every step of the payment route is 200 milliseconds slower",
+ "expect": [
+ {
+ "target": "step=\\*",
+ "fault": [
+ "delay"
+ ],
+ "duration": "200ms",
+ "in": "payment"
+ }
+ ]
+ }
+ ]
+}