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 3646c6c7536e CAMEL-23688: camel-jbang - Tests for the questions of 
camel ask (#27981)
3646c6c7536e is described below

commit 3646c6c7536e11c3c8595a7ab17d2ead586474bd
Author: Claus Ibsen <[email protected]>
AuthorDate: Sat Oct 17 00:34:38 2026 +0200

    CAMEL-23688: camel-jbang - Tests for the questions of camel ask (#27981)
    
    Tests for camel ask with a question (34% covered), against FakeLlmServer
    playing Ollama: the answer, the tools the model calls and their results
    in the next question, a failed call of the LLM, the maximum iterations
    and the stats. FakeLlmServer can now reply in sequence, such as a tool
    call and then the answer. They found these bugs, fixed here:
    
    * camel ask exited with 0 without an answer when the call of the LLM
      failed, such as with an error of the model or a refused connection:
      the error answer of the client was taken as the final answer.
    * The model in use, the integration, the note about a small model and
      the output of --show-tools and --show-stats were printed on stdout
      with the answer; they go to stderr, as with camel explain.
    
    Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |   7 +
 .../modules/ROOT/pages/camel-jbang-ai.adoc         |   4 +
 .../apache/camel/dsl/jbang/core/commands/Ask.java  |  34 +++--
 .../camel/dsl/jbang/core/commands/AskLoopTest.java | 157 +++++++++++++++++++++
 .../dsl/jbang/core/commands/FakeLlmServer.java     |  22 ++-
 5 files changed, 204 insertions(+), 20 deletions(-)

diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index cfd8900b0488..945722646904 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -506,6 +506,13 @@ or follows, like Enter does.
 * `camel ask` with an OpenAI-compatible server that gives the arguments of a 
tool call as a JSON object, instead of JSON
   text, calls the tool with these arguments (it called it without arguments).
 
+=== camel-jbang - camel ask: the exit code and the output of a question
+
+* `camel ask` exits with `1` when the call of the LLM fails, such as with an 
error of the model or a refused connection:
+  it exited with `0` without an answer.
+* `camel ask` with a question prints only the answer on stdout. The model it 
uses, the integration it asks about, the
+  note about a small model, and the output of `--show-tools` and 
`--show-stats` go to stderr.
+
 === camel-jbang - exit codes and errors of more commands
 
 * `camel export` does not clean the export directory when it is the current 
directory, or one that contains it, unless
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-ai.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-ai.adoc
index c83a6760c54b..afd1da07aec9 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-ai.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-ai.adoc
@@ -51,6 +51,10 @@ Use `--show-tools` to see tool calls and results as they 
happen:
 camel ask "show me the route structure" --show-tools
 ----
 
+With a question, the answer is the only output on stdout, so a script can use 
it: the model in use, the tool calls of
+`--show-tools` and the token usage of `--show-stats` are printed on stderr. 
`camel ask` exits with `1` when the LLM
+cannot answer, such as when it is not reachable or the call fails.
+
 === What asks first
 
 Most tools only read, and run without asking. A call that changes the project,
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java
index e02bff95386d..c5cd94ebcdd0 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java
@@ -189,12 +189,12 @@ public class Ask extends CamelCommand {
         }
 
         String userQuestion = String.join(" ", question);
-        printer().println("Using " + client.model + " (" + client.apiType + ", 
" + (core ? "core" : "full")
-                          + " tools) to answer your question...");
+        // on stderr, so stdout has the answer only
+        printer().printDiagnostic("Using " + client.model + " (" + 
client.apiType + ", " + (core ? "core" : "full")
+                                  + " tools) to answer your question...");
         if (process != null) {
-            printer().println("Target: " + process.name() + " (PID " + 
process.pid() + ")");
+            printer().printDiagnostic("Target: " + process.name() + " (PID " + 
process.pid() + ")");
         }
-        printer().println();
 
         List<LlmClient.Message> messages = new ArrayList<>();
         return runAgentLoop(client, systemPrompt, toolDefs, messages, 
userQuestion);
@@ -252,7 +252,8 @@ public class Ask extends CamelCommand {
         boolean corrected = false;
         for (int i = 0; i < maxIterations; i++) {
             LlmClient.ChatResponse response = 
client.chatWithTools(systemPrompt, messages, tools);
-            if (response == null) {
+            if (response == null || isFailed(response)) {
+                // the client printed why
                 printer().printErr("Failed to get response from LLM");
                 return 1;
             }
@@ -264,11 +265,12 @@ public class Ask extends CamelCommand {
                 List<LlmClient.ToolResult> results = new ArrayList<>();
                 for (LlmClient.ToolCall toolCall : response.toolCalls()) {
                     if (showTools) {
-                        printer().println("[tool] " + toolCall.name() + "(" + 
toolCall.arguments().toJson() + ")");
+                        printer().printDiagnostic(
+                                "[tool] " + toolCall.name() + "(" + 
toolCall.arguments().toJson() + ")");
                     }
                     String result = askTools.executeTool(toolCall.name(), 
toolCall.arguments());
                     if (showTools) {
-                        printer().println("[result] " + truncate(result, 200));
+                        printer().printDiagnostic("[result] " + 
truncate(result, 200));
                     }
                     results.add(new LlmClient.ToolResult(toolCall.id(), 
result));
                 }
@@ -284,9 +286,8 @@ public class Ask extends CamelCommand {
                     corrected = true;
                     
messages.add(LlmClient.Message.assistantWithToolCalls(response.text(), 
List.of()));
                     
messages.add(LlmClient.Message.user(AnswerChecks.correctionRequest(problems)));
-                    printer().println();
-                    printer().println("(the answer has an invalid simple 
expression: asking for a corrected answer)");
-                    printer().println();
+                    printer().printDiagnostic(
+                            "(the answer has an invalid simple expression: 
asking for a corrected answer)");
                     continue;
                 }
                 
messages.add(LlmClient.Message.assistantWithToolCalls(response.text(), 
List.of()));
@@ -313,8 +314,15 @@ public class Ask extends CamelCommand {
                     
.append(LlmClient.formatTokens(usage.totalTokens())).append(" total tokens");
         }
         sb.append(")");
-        printer().println();
-        printer().println(sb.toString());
+        printer().printDiagnostic(sb.toString());
+    }
+
+    /**
+     * Whether the call of the LLM failed: the client answers with the error 
stop reason, and nothing else.
+     */
+    static boolean isFailed(LlmClient.ChatResponse response) {
+        return "error".equals(response.stopReason()) && response.text() == null
+                && (response.toolCalls() == null || 
response.toolCalls().isEmpty());
     }
 
     // ---- Process discovery (delegates to RuntimeHelper) ----
@@ -364,7 +372,7 @@ public class Ask extends CamelCommand {
     private void warnSmallModel(String name, double billions) {
         String note = smallModelNote(name, billions);
         if (note != null) {
-            printer().println(note);
+            printer().printDiagnostic(note);
         }
     }
 
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/AskLoopTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/AskLoopTest.java
new file mode 100644
index 000000000000..7160966cbddf
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/AskLoopTest.java
@@ -0,0 +1,157 @@
+/*
+ * 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;
+
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import picocli.CommandLine;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * camel ask with a question: the model answers, or calls tools until it 
answers, against a local server that plays
+ * Ollama (no running integration). The answer is the only output on stdout.
+ */
+class AskLoopTest extends CamelCommandBaseTestSupport {
+
+    @TempDir
+    Path home;
+
+    private Path previousHome;
+    private FakeLlmServer server;
+
+    @BeforeEach
+    void start() throws Exception {
+        // no running integrations
+        previousHome = CommandLineHelper.getHomeDir();
+        CommandLineHelper.useHomeDir(home.toString());
+        server = new FakeLlmServer("unused");
+    }
+
+    @AfterEach
+    void stop() {
+        server.close();
+        CommandLineHelper.useHomeDir(previousHome.toString());
+    }
+
+    @Test
+    void theAnswerIsTheOutput() throws Exception {
+        server.reply("/api/chat", 200, answer("The routes are fine"));
+
+        assertThat(command().doCall()).isZero();
+
+        assertThat(printer.getStdout()).isEqualTo("The routes are fine");
+        assertThat(printer.getStderr()).isEqualTo("Using test (ollama, core 
tools) to answer your question...");
+        JsonArray messages = (JsonArray) server.lastRequest().get("messages");
+        assertThat(((JsonObject) 
messages.get(1)).getString("content")).isEqualTo("what routes are running?");
+    }
+
+    @Test
+    void theToolsTheModelCallsAreRunAndTheirResultsGiven() throws Exception {
+        server.reply("/api/chat", 200, toolCall("no_such_tool"), 
answer("Done"));
+
+        assertThat(command("--show-tools").doCall()).isZero();
+
+        assertThat(printer.getStdout()).isEqualTo("Done");
+        assertThat(printer.getStderr()).contains("[tool] 
no_such_tool({\"name\":\"orders\"})",
+                "[result] Unknown tool: no_such_tool");
+        // the second question has the call and its result
+        JsonArray messages = (JsonArray) server.lastRequest().get("messages");
+        JsonObject result = (JsonObject) messages.get(messages.size() - 1);
+        assertThat(result.getString("role")).isEqualTo("tool");
+        assertThat(result.getString("content")).isEqualTo("Unknown tool: 
no_such_tool");
+    }
+
+    @Test
+    void aFailedCallOfTheLlmIsAFailure() throws Exception {
+        server.reply("/api/chat", 500, "{\"error\":\"model runner has 
crashed\"}");
+
+        assertThat(command().doCall()).isEqualTo(1);
+
+        assertThat(printer.getStdout()).isEmpty();
+        assertThat(printer.getStderr()).contains("LLM returned status: 500", 
"model runner has crashed",
+                "Failed to get response from LLM");
+    }
+
+    @Test
+    void noAnswerWithinTheMaximumIterationsIsAFailure() throws Exception {
+        server.reply("/api/chat", 200, toolCall("no_such_tool"));
+
+        assertThat(command("--max-iterations=2").doCall()).isEqualTo(1);
+
+        assertThat(printer.getStderr()).contains("Reached maximum iterations 
(2) without a final answer.");
+    }
+
+    @Test
+    void theStatsOnStderr() throws Exception {
+        server.reply("/api/chat", 200, answer("The routes are fine"));
+
+        assertThat(command("--show-stats").doCall()).isZero();
+
+        assertThat(printer.getStdout()).isEqualTo("The routes are fine");
+        assertThat(printer.getStderr()).contains("10 input / 5 output / 15 
total tokens");
+    }
+
+    private Ask command(String... args) {
+        Ask command = new Ask(new CamelJBangMain().withPrinter(printer));
+        CommandLine.populateCommand(command, Stream.concat(
+                Stream.of("what routes are running?", "--url=" + server.url(), 
"--api-type=ollama", "--model=test"),
+                Arrays.stream(args)).toArray(String[]::new));
+        return command;
+    }
+
+    // as Ollama streams them: the answer in two parts, and the end with the 
token counts
+    private static String answer(String text) {
+        int half = text.length() / 2;
+        return String.join("\n",
+                chunk(new JsonObject(Map.of("role", "assistant", "content", 
text.substring(0, half))), false),
+                chunk(new JsonObject(Map.of("role", "assistant", "content", 
text.substring(half))), false),
+                chunk(new JsonObject(Map.of("role", "assistant", "content", 
"")), true), "");
+    }
+
+    private static String toolCall(String tool) {
+        JsonObject function = new JsonObject(Map.of("name", tool, "arguments", 
new JsonObject(Map.of("name", "orders"))));
+        JsonObject message = new JsonObject(
+                Map.of("role", "assistant", "content", "",
+                        "tool_calls", new JsonArray(List.of(new 
JsonObject(Map.of("function", function))))));
+        return String.join("\n", chunk(message, false),
+                chunk(new JsonObject(Map.of("role", "assistant", "content", 
"")), true), "");
+    }
+
+    private static String chunk(JsonObject message, boolean done) {
+        JsonObject chunk = new JsonObject();
+        chunk.put("message", message);
+        chunk.put("done", done);
+        if (done) {
+            chunk.put("done_reason", "stop");
+            chunk.put("prompt_eval_count", 10);
+            chunk.put("eval_count", 5);
+        }
+        return chunk.toJson();
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/FakeLlmServer.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/FakeLlmServer.java
index b6f8599cfc21..c78115b7a910 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/FakeLlmServer.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/FakeLlmServer.java
@@ -20,9 +20,11 @@ import java.io.IOException;
 import java.io.OutputStream;
 import java.net.InetSocketAddress;
 import java.nio.charset.StandardCharsets;
+import java.util.Deque;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentLinkedDeque;
 
 import com.sun.net.httpserver.Headers;
 import com.sun.net.httpserver.HttpExchange;
@@ -40,8 +42,8 @@ final class FakeLlmServer implements AutoCloseable {
 
     private final HttpServer server;
     private final String answer;
-    // the replies a test gave, by path
-    private final Map<String, Reply> replies = new ConcurrentHashMap<>();
+    // the replies a test gave, by path: one for each request, and the last 
one for the rest
+    private final Map<String, Deque<Reply>> replies = new 
ConcurrentHashMap<>();
     private volatile JsonObject lastRequest;
     private volatile Headers lastHeaders;
 
@@ -60,10 +62,15 @@ final class FakeLlmServer implements AutoCloseable {
     }
 
     /**
-     * Replies to the requests to the path with the status and body, instead 
of the reply of the server.
+     * Replies to the requests to the path with the status and a body for each 
request, instead of the reply of the
+     * server: the first request gets the first body, and the last body 
answers the requests after it.
      */
-    FakeLlmServer reply(String path, int status, String body) {
-        replies.put(path, new Reply(status, body));
+    FakeLlmServer reply(String path, int status, String... bodies) {
+        Deque<Reply> list = new ConcurrentLinkedDeque<>();
+        for (String body : bodies) {
+            list.add(new Reply(status, body));
+        }
+        replies.put(path, list);
         return this;
     }
 
@@ -88,10 +95,11 @@ final class FakeLlmServer implements AutoCloseable {
 
     private void handle(HttpExchange exchange) throws IOException {
         String path = exchange.getRequestURI().getPath();
-        Reply given = replies.get(path);
+        Deque<Reply> given = replies.get(path);
         if (given != null) {
             read(exchange);
-            reply(exchange, given.status(), given.body());
+            Reply next = given.size() > 1 ? given.poll() : given.peek();
+            reply(exchange, next.status(), next.body());
             return;
         }
         switch (path) {

Reply via email to