This is an automated email from the ASF dual-hosted git repository. luigidemasi pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/camel.git
commit 4f6e6e25f16b73648e081afdc04277d1ed5efcf0 Author: Luigi De Masi <[email protected]> AuthorDate: Sat Oct 10 12:40:55 2026 +0200 CAMEL-25499: Make semantic CLI output readable by default Render expert contracts, evaluation details and audit evidence as labelled text and tables. Keep machine-readable output behind --json, show an unset default expert neutrally, and display retained input including multiline and structured values. Co-authored-by: OpenAI Codex <[email protected]> --- .../modules/ROOT/pages/camel-jbang-managing.adoc | 20 ++++- .../jbang/core/commands/action/CamelSemantic.java | 94 +++++++++++++++++++--- .../commands/action/SemanticActionCommand.java | 43 ++++++++++ .../jbang/core/commands/action/SemanticAudit.java | 82 +++++++++++++++++-- .../core/commands/action/SemanticEvaluate.java | 3 +- .../core/commands/action/SemanticCommandTest.java | 71 ++++++++++++---- 6 files changed, 280 insertions(+), 33 deletions(-) diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc index ac9b0e980060..feff32ebce68 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc @@ -298,7 +298,9 @@ camel semantic get my-app --expert=guard `camel semantic` defaults to `camel semantic get`. The text output uses tables listing definition names, experts, operations, state selectors, result types and resolution -errors, followed by the known experts and default expert. `--expert` +errors, followed by the known experts and default expert. Listing does not require a +default expert: the text output shows `Default expert: none` when no default can be +resolved. `--json` retains the detailed default-selection diagnostics. `--expert` shows one expert's operations, input and result semantics, and parameter contract. Inspection does not perform inference. Use `--json` for machine-readable output. @@ -344,6 +346,11 @@ reply (default: 60000 milliseconds). A CLI timeout removes its request and asks connector to cancel unfinished work. Provider interruption is cooperative; remote or native inference may continue. Configure provider-specific request timeouts as well. +Semantic commands use readable text by default: expert operations include a parameter table, +evaluation results use labelled fields, and audit events show the record and linked evidence +in separate sections. Add `--json` to any semantic command to receive the complete JSON response +for scripts. + All semantic commands accept an integration name or PID. With no name they select the only running integration; ambiguous selection fails and lists the matching PIDs. Known versions older than 4.23 fail immediately with exit code 3. If the runtime version @@ -357,6 +364,11 @@ Use `camel semantic audit` to retrieve retained history from the application's c audit reader. It uses the local connector and performs no inference. Capture settings remain in the semantic DSL; querying does not enable auditing or change its configuration. +Stored input appears in event details and linked evaluation evidence when the expert's +semantic audit configuration explicitly enables `input.enabled`. Capture is disabled by +default, bounded by `maxChars`, and can use a redactor. Human-readable output preserves +multiline text; `--json` returns the stored structure and any omission reason. + [source,bash] ---- camel semantic audit my-app @@ -367,8 +379,10 @@ camel semantic audit my-app --event-id=EVENT_ID --json The default output is a table of event IDs, timestamps, categories, explicit route actions, evaluation statuses, experts, operations, targets, namespaces, reason codes and correlation -IDs. It also reports audit backend status, dropped records and evictions. An evaluation -status is separate from an application's allow/block decision. +IDs. A compact text summary reports whether capture is enabled, the reader, memory +retention and dropped records. Sink or observer failures appear as separate error counts; +evictions are shown below the table. An evaluation status is separate from an application's +allow/block decision. The full audit configuration and health fields are available with `--json`. `--json` returns the complete runtime response as one JSON document. `--event-id` selects one historical record and its linked evaluation evidence, including any retained provider, diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSemantic.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSemantic.java index 235148202c20..8dfb3d30ff16 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSemantic.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSemantic.java @@ -16,16 +16,17 @@ */ package org.apache.camel.dsl.jbang.core.commands.action; +import java.util.ArrayList; import java.util.List; import java.util.stream.IntStream; import com.github.freva.asciitable.AsciiTable; import com.github.freva.asciitable.Column; import com.github.freva.asciitable.HorizontalAlign; +import com.github.freva.asciitable.OverflowBehaviour; import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; import org.apache.camel.util.json.JsonArray; import org.apache.camel.util.json.JsonObject; -import org.apache.camel.util.json.Jsoner; import picocli.CommandLine; @CommandLine.Command(name = "semantic", description = "List semantic definitions and expert contracts", @@ -94,10 +95,8 @@ public class CamelSemantic extends SemanticActionCommand { new Column().header("REFERENCE").dataAlign(HorizontalAlign.LEFT).with(r -> text(r, "reference")), new Column().header("NAME").dataAlign(HorizontalAlign.LEFT).with(r -> text(r, "name")), new Column().header("ERROR").dataAlign(HorizontalAlign.LEFT).with(r -> text(r, "error"))))); - printer().println("Default expert: " + text(response, "defaultExpert")); - if (response.get("defaultError") != null) { - printer().println("Default expert error: " + response.get("defaultError")); - } + String defaultExpert = text(response, "defaultExpert"); + printer().println("Default expert: " + (defaultExpert.isEmpty() ? "none" : defaultExpert)); } private static List<JsonObject> rows(JsonArray values) { @@ -105,15 +104,92 @@ public class CamelSemantic extends SemanticActionCommand { } private void renderOperations(JsonArray operations) { - for (int i = 0; i < operations.size(); i++) { - JsonObject operation = operations.getMap(i); + for (JsonObject operation : rows(operations)) { + printer().println(); printer().println(text(operation, "name") + " (" + text(operation, "resultType") + "): " + text(operation, "description")); - printer().println(Jsoner.prettyPrint(Jsoner.serialize(operation.get("contract")))); + JsonObject contract = operation.getMap("contract"); + if (contract == null) { + continue; + } + renderField("Input", contract.get("inputTypes"), " "); + renderField("Requirements", contract.get("inputRequirements"), " "); + renderField("Result", contract.get("resultMeaning"), " "); + renderField("Labels", contract.get("labels"), " "); + renderField("Range", range(contract, "minimum", "maximum"), " "); + renderField("Score levels", contract.get("scoreLevelsParameter"), " "); + for (String metric : List.of("probability", "probabilities", "confidence")) { + if (Boolean.TRUE.equals(contract.get(metric))) { + String meaning = text(contract, "confidence".equals(metric) ? "confidenceMeaning" : "probabilityMeaning"); + renderField(fieldLabel(metric), meaning.isEmpty() ? "available" : meaning, " "); + } + } + List<JsonObject> parameters = rows(contract.getCollection("parameters")); + if (!parameters.isEmpty()) { + printer().println(); + printer().println("Parameters:"); + printer().println(AsciiTable.getTable(AsciiTable.NO_BORDERS, parameters, List.of( + new Column().header("PARAMETER").dataAlign(HorizontalAlign.LEFT).maxWidth(24, OverflowBehaviour.NEWLINE) + .with((JsonObject p) -> text(p, "name")), + new Column().header("TYPE").dataAlign(HorizontalAlign.LEFT).maxWidth(16, OverflowBehaviour.NEWLINE) + .with(CamelSemantic::parameterType), + new Column().header("REQUIRED").dataAlign(HorizontalAlign.LEFT) + .with((JsonObject p) -> Boolean.TRUE.equals(p.get("required")) ? "yes" : "no"), + new Column().header("DETAILS").dataAlign(HorizontalAlign.LEFT).maxWidth(64, OverflowBehaviour.NEWLINE) + .with(CamelSemantic::parameterDetails)))); + } + } + } + + private static String parameterType(JsonObject parameter) { + String type = text(parameter, "type"); + if ("List".equals(type) || "Map".equals(type)) { + String itemType = text(parameter, "itemType"); + if (!itemType.isEmpty() && !"Object".equals(itemType)) { + type += "<" + itemType + ">"; + } + } + return Boolean.TRUE.equals(parameter.get("integer")) ? type + " (integer)" : type; + } + + private static String parameterDetails(JsonObject parameter) { + List<String> details = new ArrayList<>(); + if (!text(parameter, "description").isEmpty()) { + details.add(text(parameter, "description")); + } + if (!text(parameter, "omission").isEmpty()) { + details.add("When omitted: " + text(parameter, "omission")); + } + JsonArray values = parameter.getCollection("values"); + if (values != null && !values.isEmpty()) { + details.add("Allowed: " + String.join(", ", values.stream().map(SemanticActionCommand::displayText).toList())); + } + String range = range(parameter, "minimum", "maximum"); + if (!range.isEmpty()) { + details.add("Range: " + range); + } + // The schema's unbounded collection limits are implementation defaults, not useful constraints. + Object minimum = parameter.get("minSize"); + Object maximum = parameter.get("maxSize"); + if (minimum instanceof Number number && number.longValue() > 0) { + details.add("Minimum items: " + minimum); + } + if (maximum instanceof Number number && number.longValue() < Integer.MAX_VALUE) { + details.add("Maximum items: " + maximum); + } + return String.join("\n", details); + } + + private static String range(JsonObject value, String minimum, String maximum) { + String min = text(value, minimum); + String max = text(value, maximum); + if (min.isEmpty()) { + return max.isEmpty() ? "" : "<= " + max; } + return max.isEmpty() ? ">= " + min : min + " to " + max; } private static String text(JsonObject value, String key) { - return value.get(key) == null ? "" : value.get(key).toString(); + return displayText(value.get(key)); } } diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticActionCommand.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticActionCommand.java index 6f0f9dff6d3b..5d447044bf10 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticActionCommand.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticActionCommand.java @@ -17,7 +17,10 @@ package org.apache.camel.dsl.jbang.core.commands.action; import java.util.Arrays; +import java.util.Collection; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; import org.apache.camel.dsl.jbang.core.commands.exceptionhandler.UsageErrorHandler; @@ -135,6 +138,46 @@ public abstract class SemanticActionCommand extends ActionBaseCommand implements return 0; } + protected void renderField(String label, Object value, String indent) { + if (value == null) { + return; + } + if (value instanceof Map<?, ?> fields) { + if (!fields.isEmpty()) { + printer().println(indent + label + ":"); + fields.forEach((key, item) -> renderField(displayText(key), item, indent + " ")); + } + } else if (value instanceof Collection<?> items) { + if (items.stream().anyMatch(item -> item instanceof Map || item instanceof Collection)) { + int index = 1; + for (Object item : items) { + renderField(label + " " + index++, item, indent); + } + } else if (!items.isEmpty()) { + printer().println(indent + label + ": " + + items.stream().map(SemanticActionCommand::displayText).collect(Collectors.joining(", "))); + } + } else if (!displayText(value).isEmpty()) { + printer().println(indent + label + ": " + displayText(value)); + } + } + + protected static String displayText(Object value) { + // Keep provider text from introducing terminal control sequences in human-readable output. + return value == null ? "" : value.toString().replaceAll("[\\p{Cntrl}\\p{Cf}]", " "); + } + + protected static String fieldLabel(String key) { + if ("elapsedMillis".equals(key)) { + return "Elapsed (ms)"; + } + if ("durationNanos".equals(key)) { + return "Duration (ns)"; + } + String label = key.replaceAll("([a-z0-9])([A-Z])", "$1 $2"); + return Character.toUpperCase(label.charAt(0)) + label.substring(1).replace(" Id", " ID"); + } + protected abstract JsonObject request(); protected abstract void render(JsonObject response); diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticAudit.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticAudit.java index feeac68567c8..9ebf24eeb0f6 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticAudit.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticAudit.java @@ -19,6 +19,8 @@ package org.apache.camel.dsl.jbang.core.commands.action; import java.time.Instant; import java.time.format.DateTimeParseException; import java.util.List; +import java.util.Map; +import java.util.StringJoiner; import java.util.stream.IntStream; import com.github.freva.asciitable.AsciiTable; @@ -28,7 +30,6 @@ import com.github.freva.asciitable.HorizontalAlign; import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; import org.apache.camel.util.json.JsonArray; import org.apache.camel.util.json.JsonObject; -import org.apache.camel.util.json.Jsoner; import picocli.CommandLine; @CommandLine.Command(name = "audit", description = "Retrieve retained semantic audit records and linked evidence", @@ -131,6 +132,34 @@ public class SemanticAudit extends SemanticActionCommand { return super.printResponse(response); } + private void renderAuditField(String key, Object value, String indent) { + if ("input".equals(key)) { + renderInput("Input", value, indent); + } else { + renderField(fieldLabel(key), value, indent); + } + } + + private void renderInput(String label, Object value, String indent) { + if (value instanceof Map<?, ?> map && !map.isEmpty()) { + printer().println(indent + label + ":"); + map.forEach((key, item) -> renderInput(displayText(key), item, indent + " ")); + } else if (value instanceof List<?> list && !list.isEmpty()) { + printer().println(indent + label + ":"); + for (int i = 0; i < list.size(); i++) { + renderInput("[" + i + "]", list.get(i), indent + " "); + } + } else if (value instanceof String text && text.contains("\n")) { + printer().println(indent + label + ":"); + for (String line : text.split("\\R", -1)) { + printer().println(indent + " " + displayText(line)); + } + } else { + printer() + .println(indent + label + ": " + (value == null ? "null" : "".equals(value) ? "\"\"" : displayText(value))); + } + } + @Override protected void render(JsonObject response) { if (response.containsKey("error")) { @@ -138,15 +167,30 @@ public class SemanticAudit extends SemanticActionCommand { } JsonObject audit = response.getMap("audit"); if (audit != null) { - printer().println("Audit: " + audit.toJson()); + renderStatus(audit); } if (eventId != null) { - Object record = response.get("record"); - printer().println(record == null ? "Record unavailable." : Jsoner.prettyPrint(Jsoner.serialize(record))); + JsonObject record = response.getMap("record"); + if (record == null) { + printer().println("Record unavailable."); + } else { + printer().println(); + printer().println("Record:"); + record.forEach((key, value) -> renderAuditField(key, value, " ")); + } JsonArray evidence = response.getCollection("evidence"); if (evidence != null && !evidence.isEmpty()) { + printer().println(); printer().println("Evidence:"); - printer().println(Jsoner.prettyPrint(evidence.toJson())); + for (int i = 0; i < evidence.size(); i++) { + JsonObject linked = evidence.getMap(i); + printer().println(" " + displayText(linked.get("eventId"))); + linked.forEach((key, value) -> { + if (!"eventId".equals(key)) { + renderAuditField(key, value, " "); + } + }); + } } return; } @@ -169,6 +213,34 @@ public class SemanticAudit extends SemanticActionCommand { } } + private void renderStatus(JsonObject audit) { + Object capturing = audit.getOrDefault("decisionsEnabled", audit.get("enabled")); + String state = capturing instanceof Boolean enabled ? (enabled ? "enabled" : "disabled") : "unknown"; + StringJoiner summary = new StringJoiner(" | "); + summary.add("Audit: " + state); + if (audit.get("reader") != null) { + summary.add("Reader: " + audit.get("reader")); + } + if ("memory".equals(audit.get("reader")) && audit.get("retained") != null && audit.get("capacity") != null) { + summary.add("Retained: " + audit.get("retained") + "/" + audit.get("capacity")); + } + if (audit.get("dropped") != null) { + summary.add("Dropped: " + audit.get("dropped")); + } + printer().println(summary.toString()); + JsonObject sinkErrors = audit.getMap("sinkErrors"); + if (sinkErrors != null) { + sinkErrors.forEach((sink, count) -> { + if (count instanceof Number number && number.longValue() > 0) { + printer().println("Sink errors (" + sink + "): " + count); + } + }); + } + if (audit.get("observerErrors") instanceof Number errors && errors.longValue() > 0) { + printer().println("Observer errors: " + errors); + } + } + private static ColumnData<JsonObject> column(String heading, String field) { return new Column().header(heading).dataAlign(HorizontalAlign.LEFT) .with((JsonObject row) -> row.get(field) == null ? "" : row.get(field).toString()); diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticEvaluate.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticEvaluate.java index cee694a82653..481aa5f9eb13 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticEvaluate.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticEvaluate.java @@ -26,7 +26,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; import org.apache.camel.util.json.JsonObject; -import org.apache.camel.util.json.Jsoner; import picocli.CommandLine; @CommandLine.Command(name = "eval", description = "Evaluate a semantic definition or expert operation", @@ -169,7 +168,7 @@ public class SemanticEvaluate extends SemanticActionCommand { for (String key : List.of("evaluation", "status", "value", "probability", "probabilities", "confidence", "metadata", "elapsedMillis", "error")) { if (response.containsKey(key)) { - printer().println(key + ": " + Jsoner.serialize(response.get(key))); + renderField(fieldLabel(key), response.get(key), ""); } } } diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticCommandTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticCommandTest.java index 0dda78737cd6..b8f667bc9a07 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticCommandTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticCommandTest.java @@ -22,7 +22,9 @@ import java.io.StringWriter; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -87,8 +89,9 @@ class SemanticCommandTest { JsonObject response = json(""" {"evaluations":[{"name":"safe","expert":"guard","operation":"detect", "state":"${body}","resultType":"boolean","error":"definition error"}], - "experts":[{"reference":"guard","name":"Safety guard","error":"expert error"}], - "defaultError":"No default expert"} + "experts":[{"reference":"guard","name":"Safety guard","error":"expert error"}, + {"reference":"classifier","name":"Classifier"}], + "defaultError":"Semantic language requires exactly one eligible expert"} """); AtomicReference<JsonObject> request = new AtomicReference<>(); assertEquals(0, respond(new CamelSemantic(main()), r -> { @@ -103,7 +106,10 @@ class SemanticCommandTest { assertFalse(printer.getOutput().contains("\t")); assertTrue(printer.getOutput().contains("Safety guard")); assertTrue(printer.getOutput().contains("expert error")); - assertTrue(printer.getOutput().contains("No default expert")); + assertTrue(printer.getOutput().contains("Classifier")); + assertTrue(printer.getOutput().contains("Default expert: none")); + assertFalse(printer.getOutput().contains("Default expert error:")); + assertFalse(printer.getOutput().contains("requires exactly one eligible expert")); assertEquals("", errors.toString()); } @@ -115,21 +121,33 @@ class SemanticCommandTest { assertEquals("", errors.toString()); } - @Test - void showsExpertOperationAndParameterContract() throws Exception { + @ParameterizedTest + @ValueSource(booleans = { false, true }) + void showsExpertOperationAndParameterContract(boolean jsonOutput) throws Exception { JsonObject response = json(""" {"operations":[{"name":"rank","description":"Rank text","resultType":"score", "contract":{"inputTypes":["text"],"parameters":[{"name":"criteria","type":"List", - "itemType":"String","required":true}],"resultMeaning":"quality"}}]} + "itemType":"String","required":true,"description":"Ordered levels","minSize":2,"maxSize":10}, + {"name":"threshold","type":"Number","minimum":0,"maximum":1,"omission":"Expert default"}], + "minimum":0,"maximum":1,"resultMeaning":"quality","probability":true, + "probabilityMeaning":"Likelihood","confidence":false}}]} """); assertEquals(0, respond(new CamelSemantic(main()), r -> { assertEquals("grader", r.get("expert")); assertFalse(r.containsKey("overview")); return response; - }, "--expert=grader")); - assertTrue(printer.getOutput().contains("rank (score): Rank text")); - assertTrue(printer.getOutput().contains("criteria")); - assertTrue(printer.getOutput().contains("quality")); + }, "--expert=grader", "--json=" + jsonOutput)); + if (jsonOutput) { + assertEquals(response, json(printer.getOutput())); + } else { + for (String value : List.of("rank (score): Rank text", "Input: text", "Result: quality", "Range: 0 to 1", + "Probability: Likelihood", "PARAMETER", "TYPE", "REQUIRED", "DETAILS", "criteria", "List<String>", + "yes", "Ordered levels", "Minimum items: 2", "Maximum items: 10", "When omitted: Expert default")) { + assertTrue(printer.getOutput().contains(value), printer.getOutput()); + } + assertFalse(printer.getOutput().contains("\"inputTypes\"")); + assertFalse(printer.getOutput().contains("Confidence:")); + } } @Test @@ -201,7 +219,7 @@ class SemanticCommandTest { }, "--expert=grader", "--operation=rank", "--input=json:[\"one\",\"two\"]", "--parameter=criteria=json:[\"poor\",\"good\"]", "--parameter=threshold=json:0.7", "--parameter=strict=json:true", "--parameter=description=plain text")); - for (String key : List.of("value", "probability", "probabilities", "confidence", "elapsedMillis")) { + for (String key : List.of("Value", "Probability", "Probabilities", "Confidence", "Elapsed (ms)")) { assertTrue(printer.getOutput().contains(key + ":")); } } @@ -486,7 +504,8 @@ class SemanticCommandTest { assertEquals(50L, request.getLong("limit")); assertEquals(2, request.size()); return jsonUnchecked(""" - {"audit":{"reader":"memory","enabled":true,"dropped":2}, + {"audit":{"reader":"memory","enabled":false,"decisionsEnabled":true, + "retained":9,"capacity":1000,"dropped":2,"sinkErrors":{"memory":0,"log":3},"observerErrors":1}, "records":[{"eventId":"decision-1","timestamp":"2026-10-10T08:00:00Z", "category":"decision","action":"block","operation":"tools/call","target":"support-request", "namespace":"test","reasonCode":"policy_denied","correlationId":"request-123"}, @@ -497,10 +516,13 @@ class SemanticCommandTest { })); for (String value : List.of("EVENT ID", "TIMESTAMP", "ACTION", "STATUS", "decision-1", "evaluation-1", "block", "success", "guard", "tools/call", "support-request", "policy_denied", "request-123", - "Next cursor: opaque:cursor", "Evicted: 7", "\"dropped\":2")) { + "Next cursor: opaque:cursor", "Evicted: 7", "Audit: enabled | Reader: memory | Retained: 9/1000 | Dropped: 2", + "Sink errors (log): 3", "Observer errors: 1")) { assertTrue(printer.getOutput().contains(value), printer.getOutput()); } assertFalse(printer.getOutput().contains("allow")); + assertFalse(printer.getOutput().contains("Audit: {")); + assertFalse(printer.getOutput().contains("Sink errors (memory)")); assertEquals("", errors.toString()); } @@ -536,6 +558,7 @@ class SemanticCommandTest { "record":{"eventId":"decision-1","category":"decision","action":"block", "evidence":["evaluation-1","evicted-1"]}, "evidence":[{"eventId":"evaluation-1","provider":"safety-provider","model":"guard-v1","revision":"r1", + "input":{"prompt":"stored prompt"},"inputRedacted":true, "result":{"value":false},"semantics":{"meaning":"Injection detected"}}, {"eventId":"evicted-1","unavailable":true}]} """); @@ -549,13 +572,32 @@ class SemanticCommandTest { assertEquals(response, json(printer.getOutput())); } else { for (String value : List.of("decision-1", "Evidence:", "evaluation-1", "safety-provider", "guard-v1", - "Injection detected", "evicted-1", "\"unavailable\": true")) { + "Injection detected", "Input:", "prompt: stored prompt", "Input Redacted: true", "evicted-1", + "Unavailable: true")) { assertTrue(printer.getOutput().contains(value), printer.getOutput()); } } assertEquals("", errors.toString()); } + @Test + void auditShowsMultilineAndEmptyStoredInputWithoutTerminalControls() throws Exception { + JsonObject record = new JsonObject(); + record.put("eventId", "evaluation-1"); + Map<String, Object> input = new LinkedHashMap<>(); + input.put("text", "hello\nworld\u001b[31m"); + input.put("empty", ""); + input.put("missing", null); + input.put("items", List.of()); + record.put("input", input); + assertEquals(0, respond(new SemanticAudit(main()), request -> new JsonObject(Map.of("record", record)), + "--event-id=evaluation-1")); + for (String value : List.of("text:\n", "hello\n", "world", "empty: \"\"", "missing: null", "items: []")) { + assertTrue(printer.getOutput().contains(value), printer.getOutput()); + } + assertFalse(printer.getOutput().contains("\u001b")); + } + @ParameterizedTest @ValueSource(ints = { 1, 200 }) void auditAcceptsPageLimitBoundsAndEmptyHistory(int limit) throws Exception { @@ -566,6 +608,7 @@ class SemanticCommandTest { """); }, "--limit=" + limit)); assertTrue(printer.getOutput().contains("No retained audit records match this query.")); + assertTrue(printer.getOutput().contains("Audit: disabled | Reader: memory")); assertEquals("", errors.toString()); }
