This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-26721-chaos-describe-input in repository https://gitbox.apache.org/repos/asf/camel.git
commit 4316bdaf16e04e444d7bb3806ab258b0c3d827a0 Author: Claus Ibsen <[email protected]> AuthorDate: Sat Nov 7 21:08:23 2026 +0100 CAMEL-26721: camel-jbang - describe the chaos you want in your own words, and the AI turns it into checked rules The last part of S2-5 in the stage 2 order decided on CAMEL-26607. On the chaos screen, d (describe with AI, when the AI panel is there) opens an input for the chaos the user wants, such as "the order route should fail 5% of the time, or when the country is spain". Enter sends it to the AI panel at once (MonitorContext.AskAi.askNow, AiPanel.askNow; when the panel is busy it waits in the input) with the routes and sends of the integration, its rules now, the rule syntax in short, and the steps: check the rules, show each in plain words with what it hits, and add them. The AI panel had no chaos tools (CAMEL-26608), which a live try with a local model showed: it looked around the TUI for 76s and 18 tool calls. So the chaos tool group of the panel now has tui_chaos_check (read-only), tui_chaos_add and tui_chaos_change, while chaos is on; they run the shared chaos_check, chaos_add and chaos_change, and add and change ask the user first (ToolConfirms.chaos, which now names the integration and lists several rules one per line). Like the semantic tools they are left out of the full tool set unless their group is loaded, so the prompt of an integration without chaos does not grow. The same try then took 13s and one tool call. 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 | 43 +++++++++- .../jbang/core/commands/ai/ToolConfirmsTest.java | 6 ++ .../camel/dsl/jbang/core/commands/tui/AiPanel.java | 11 +++ .../dsl/jbang/core/commands/tui/CamelMonitor.java | 8 ++ .../dsl/jbang/core/commands/tui/ChaosAiPrompt.java | 84 +++++++++++++++++++ .../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 | 91 +++++++++++++++++++++ .../core/commands/tui/McpFacadeConfirmTest.java | 26 ++++++ .../jbang/core/commands/tui/SourceSizeTest.java | 2 +- 17 files changed, 426 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 cc4a7d617e96..cf5a29cdaca8 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 @@ -285,6 +285,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..54d15487ed5b 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,55 @@ 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; }; } + /** + * The rules of a text with several separated by {@code ;}, as camel.chaos.rules: a {@code ;} in quotes does not. + */ + 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/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 a26335cc6e09..bb16c25468fa 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..3898bbf4234b 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,90 @@ 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) { + 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:\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"); + 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..6fb33bb27505 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)); + } 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 b207871f1fd7..74ae874f420b 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 @@ -403,5 +403,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 c321456cf809..29df37a7f5ae 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..de969a3821b1 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,100 @@ 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:\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 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/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
