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

commit d0091452c3892300f9f5c40b44847c9e812138a5
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Sep 8 17:22:56 2026 +0200

    CAMEL-24656: camel-jbang TUI - trim verbose tool descriptions and cap the 
AI panel history
    
    Shorten the descriptions and schemas of the largest tools (tui_draw,
    tui_draw_shape, tui_catalog_doc, tui_navigate, tui_get_state, 
tui_get_options,
    tui_get_status) without dropping any parameter; tui_draw no longer repeats 
the
    tui_draw_shape vocabulary, and tui_get_options no longer tells the model to
    call it first on every task. The full tool set drops from ~7.3k to ~6.6k
    tokens and the core set to ~2.7k.
    
    The model history is now bounded: a tool result is capped at 16k characters
    when it enters the history (the AI log keeps the full text), tool results 
from
    turns before the previous one are compacted to their first 400 characters
    once a turn is answered, and the oldest turns are dropped beyond 20 
questions.
    Whole turns are removed so tool calls never lose their matching results.
    
    Co-Authored-By: Claude Fable 5.1 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../camel/dsl/jbang/core/commands/tui/AiPanel.java |  81 ++++++++++++-
 .../jbang/core/commands/tui/TuiToolRegistry.java   | 130 +++++++--------------
 .../commands/tui/AiPanelHistoryCompactionTest.java | 106 +++++++++++++++++
 3 files changed, 227 insertions(+), 90 deletions(-)

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 a640ec44cc02..a11279f6aace 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
@@ -72,6 +72,21 @@ import org.apache.camel.util.json.JsonObject;
 class AiPanel {
 
     private static final int MAX_ITERATIONS = 10;
+    /**
+     * Longest tool result handed to the model. The AI log keeps the full 
text; the model gets the head plus a note,
+     * because a single log or table dump can otherwise be larger than the 
whole system prompt.
+     */
+    static final int MAX_TOOL_RESULT_CHARS = 16_000;
+    /**
+     * Tool results from turns before the previous one are shrunk to this many 
characters once the turn is answered. The
+     * model's own answer already summarises them, and the whole history is 
re-sent (and re-processed by a local model)
+     * on every request.
+     */
+    static final int COMPACT_TOOL_RESULT_CHARS = 400;
+    /**
+     * Oldest turns are dropped beyond this many user questions in one 
conversation.
+     */
+    static final int MAX_HISTORY_TURNS = 20;
     private static final int MAX_LOG_ENTRIES = 200;
     private static final DateTimeFormatter TIME_FMT
             = 
DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.systemDefault());
@@ -1035,7 +1050,7 @@ class AiPanel {
                     log(LogLevel.TOOL, toolCall.name(), 
toolCall.arguments().toJson());
                     String result = executeTuiTool(toolCall.name(), 
toolCall.arguments());
                     log(LogLevel.RESULT, toolCall.name(), result);
-                    results.add(new LlmClient.ToolResult(toolCall.id(), 
result));
+                    results.add(new LlmClient.ToolResult(toolCall.id(), 
truncateToolResult(result)));
                 }
                 messages.add(LlmClient.Message.toolResults(results));
             } else {
@@ -1055,6 +1070,7 @@ class AiPanel {
                 }
                 scrollOffset = 0;
                 messages.add(LlmClient.Message.assistantWithToolCalls(text, 
List.of()));
+                compactHistory(messages, MAX_HISTORY_TURNS, 
COMPACT_TOOL_RESULT_CHARS);
                 return;
             }
         }
@@ -1856,6 +1872,69 @@ class AiPanel {
         return defs;
     }
 
+    /**
+     * Caps a tool result before it enters the model history; the AI log keeps 
the full text.
+     */
+    static String truncateToolResult(String result) {
+        if (result == null || result.length() <= MAX_TOOL_RESULT_CHARS) {
+            return result;
+        }
+        return result.substring(0, MAX_TOOL_RESULT_CHARS)
+               + "\n... [truncated, " + (result.length() - 
MAX_TOOL_RESULT_CHARS)
+               + " more characters; narrow the request (filter, limit, 
section) to see the rest]";
+    }
+
+    /**
+     * Keeps the model history bounded after a turn is answered: tool results 
from turns before the previous one are
+     * shrunk to their head, and the oldest turns are dropped beyond {@code 
maxTurns} user questions. The previous turn
+     * is kept intact so an immediate follow-up can still refer to what was 
just fetched. Whole turns are removed (user
+     * message through the final answer) so assistant tool calls never lose 
their matching results.
+     */
+    static void compactHistory(List<LlmClient.Message> history, int maxTurns, 
int compactChars) {
+        if (history == null || history.isEmpty()) {
+            return;
+        }
+        List<Integer> userIndexes = new ArrayList<>();
+        for (int i = 0; i < history.size(); i++) {
+            LlmClient.Message m = history.get(i);
+            if ("user".equals(m.role()) && m.toolCalls() == null && 
m.toolResults() == null) {
+                userIndexes.add(i);
+            }
+        }
+        if (userIndexes.size() > maxTurns) {
+            int keepFrom = userIndexes.get(userIndexes.size() - maxTurns);
+            history.subList(0, keepFrom).clear();
+            int dropped = userIndexes.size() - maxTurns;
+            userIndexes = userIndexes.subList(dropped, 
userIndexes.size()).stream()
+                    .map(index -> index - keepFrom).toList();
+        }
+        // everything before the previous turn (i.e. before the second-last 
user message) is compacted
+        if (userIndexes.size() < 2) {
+            return;
+        }
+        int compactBefore = userIndexes.get(userIndexes.size() - 2);
+        for (int i = 0; i < compactBefore; i++) {
+            LlmClient.Message m = history.get(i);
+            if (m.toolResults() == null || m.toolResults().isEmpty()) {
+                continue;
+            }
+            boolean changed = false;
+            List<LlmClient.ToolResult> compacted = new 
ArrayList<>(m.toolResults().size());
+            for (LlmClient.ToolResult tr : m.toolResults()) {
+                String content = tr.content();
+                if (content != null && content.length() > compactChars) {
+                    content = content.substring(0, compactChars)
+                              + "\n... [earlier result compacted; call the 
tool again for the full data]";
+                    changed = true;
+                }
+                compacted.add(new LlmClient.ToolResult(tr.toolCallId(), 
content));
+            }
+            if (changed) {
+                history.set(i, LlmClient.Message.toolResults(compacted));
+            }
+        }
+    }
+
     private String executeTuiTool(String name, JsonObject args) {
         if (toolRegistry == null) {
             return "Error: TUI tools not available";
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 09bd8c1bcb21..1ab25da7511f 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
@@ -187,14 +187,9 @@ class TuiToolRegistry {
                 Map.of("limit", propDef("integer", "Maximum number of events 
to return (default 50)")))));
         tools.add(toToolDef(toolDef(
                 "tui_get_state",
-                "Returns the current TUI navigation state: active tab, 
selected integration, "
-                                 + "and integration count. "
-                                 + "Includes a 'selection' field with 
structured metadata about the active list/table. "
-                                 + "captionVisible indicates if a caption 
overlay is on screen. "
-                                 + "keystrokesVisible indicates if the 
keystroke overlay is on. "
-                                 + "detailFocused (boolean, present on tabs 
with master/detail panels) indicates "
-                                 + "which panel has focus: true=detail panel, 
false=table panel. "
-                                 + "Press Tab to toggle focus. Up/Down and 
PgUp/PgDn operate on the focused panel.",
+                "Current TUI navigation state: active tab, selected 
integration and PID, integration count, the "
+                                 + "active list/table selection, overlay flags 
and (on master/detail tabs) which "
+                                 + "panel has focus.",
                 Map.of())));
         tools.add(toToolDef(toolDef(
                 "tui_show_caption",
@@ -208,20 +203,14 @@ class TuiToolRegistry {
                 List.of("text"))));
         tools.add(toToolDef(toolDef(
                 "tui_navigate",
-                "Navigates the TUI: switch tabs and/or select an integration. "
-                                + "All parameters are optional — set whichever 
you want to change. "
-                                + "Tab names: Overview, Log, Activity, 
Diagram, Routes, Endpoints, HTTP, Inspect, "
-                                + "Circuit Breaker, Health, Spans, Process. "
-                                + "Use 'route' to select a route in the 
Diagram topology, "
-                                + "and 'node' to drill down into a route and 
select a specific processor/EIP node. "
-                                + "Returns screen content and selection 
metadata after navigating.",
-                Map.of("tab", propDef("string", "Tab to switch to (e.g. 
'Routes', 'Activity', 'Diagram')"),
+                "Changes what the user sees: switch tab, select an 
integration, select a route in the Diagram "
+                                + "tab, or drill into a processor node. Every 
parameter is optional. Returns the screen "
+                                + "and selection afterwards. Do not use it 
just to read data; the tui_get_* tools do that.",
+                Map.of("tab", propDef("string", "Tab to switch to, e.g. 
Routes, Log, Diagram (see tui_get_options)"),
                         "integration", propDef("string", "Integration name or 
PID to select"),
-                        "route", propDef("string",
-                                "Route ID to select in the Diagram tab 
topology (e.g. 'order-dispatcher')"),
+                        "route", propDef("string", "Route ID to select in the 
Diagram tab"),
                         "node", propDef("string",
-                                "Processor/EIP node ID to select within a 
drilled-down route (e.g. 'multicast1'). "
-                                                  + "If 'route' is also 
provided, drills into that route first")))));
+                                "Processor/EIP node ID to select inside the 
route (drills into 'route' first when given)")))));
 
         tools.add(toToolDef(toolDef(
                 "tui_send_keys",
@@ -239,13 +228,9 @@ class TuiToolRegistry {
                 List.of("keys"))));
         tools.add(toToolDef(toolDef(
                 "tui_get_options",
-                "IMPORTANT: Call this FIRST before any other tui_ tool when 
starting a new task. "
-                                   + "Returns all available tabs with 
descriptions and running integrations. "
-                                   + "Each tab description says what data it 
provides — match the user's question "
-                                   + "keywords to tab descriptions to find the 
right data source in one step "
-                                   + "(e.g. 'kafka offset' → find Kafka tab → 
tui_get_table(tab='Kafka')). "
-                                   + "This avoids wasting calls piecing data 
from logs, spans, and endpoints "
-                                   + "when a dedicated tab already has exactly 
the needed information.",
+                "Lists every tab with a description of the data it provides, 
plus the running integrations. "
+                                   + "Use it when unsure which tab holds the 
data for a question (e.g. 'kafka offset' "
+                                   + "-> Kafka tab), then read that tab with 
tui_get_table.",
                 Map.of())));
         tools.add(toToolDef(toolDef(
                 "tui_wait_for_idle",
@@ -281,36 +266,18 @@ class TuiToolRegistry {
                 List.of("seconds"))));
         tools.add(toToolDef(toolDef(
                 "tui_draw",
-                "Draws characters at specific screen coordinates as an overlay 
on top of the TUI. "
-                            + "Use this to highlight areas, annotate the 
screen for the human, "
-                            + "draw shapes, or create fun emoji art. "
-                            + "All cells are sent in a single call to avoid 
chatty networking. "
-                            + "Coordinates are 0-based and match the screen 
grid from tui_get_screen. "
-                            + "Characters can be any unicode including emoji. "
-                            + "The drawing overlays on top of existing content 
without modifying it. "
-                            + "Use with tui_show_caption to explain what you 
drew.",
+                "Draws an overlay on top of the TUI screen in one call: many 
shapes (batch of the tui_draw_shape "
+                            + "parameters) and/or individual characters, 
including emoji. Coordinates are 0-based and "
+                            + "match tui_get_screen. Use with tui_show_caption 
to explain what you drew.",
                 Map.of("cells", propDef("array",
-                        "Array of cell objects to draw. Each cell has: "
-                                                 + "x (integer, column), y 
(integer, row), "
-                                                 + "char (string, character to 
draw), "
-                                                 + "fg (string, optional 
foreground color: red/green/blue/yellow/cyan/magenta/white/gray/black), "
-                                                 + "bg (string, optional 
background color, same values), "
-                                                 + "bold (boolean, optional)"),
+                        "Cell objects: x, y, char, optional fg/bg color name, 
optional bold"),
                         "shapes", propDef("array",
-                                "Array of shape objects to draw (batch mode). 
Each shape has: "
-                                                   + "shape (string, required: 
box/highlight/underline/arrow-down/arrow-up/arrow-left/arrow-right/text), "
-                                                   + "x (integer, column), y 
(integer, row), "
-                                                   + "width (integer, for 
box/highlight/underline), "
-                                                   + "height (integer, for 
box/highlight), "
-                                                   + "length (integer, for 
arrows), "
-                                                   + "text (string, for text 
shape), "
-                                                   + "color (string: 
red/green/blue/yellow/cyan/magenta/white/gray/black). "
-                                                   + "Use shapes instead of 
cells for high-level drawing in a single call."),
+                                "Shape objects with the same fields as 
tui_draw_shape: shape, x, y, width, height, "
+                                                   + "length, text, color"),
                         "duration", propDef("integer",
-                                "Auto-dismiss drawing after this many seconds. 
"
-                                                       + "If omitted, drawing 
stays until cleared with tui_draw_clear or replaced by another tui_draw call."),
+                                "Auto-dismiss after this many seconds; 
otherwise stays until tui_draw_clear or the next tui_draw"),
                         "append", propDef("boolean",
-                                "If true, add cells to the existing drawing 
instead of replacing it. Default false.")),
+                                "Add to the existing drawing instead of 
replacing it (default false)")),
                 List.of())));
         tools.add(toToolDef(toolDef(
                 "tui_draw_clear",
@@ -320,13 +287,10 @@ class TuiToolRegistry {
 
         tools.add(toToolDef(toolDef(
                 "tui_draw_shape",
-                "Draws a predefined shape on the TUI screen overlay. "
-                                  + "Much easier than constructing individual 
cells with tui_draw. "
-                                  + "Combine with tui_locate for precise 
positioning.",
+                "Draws one shape on the TUI screen overlay. Combine with 
tui_locate for precise positioning.",
                 Map.of("shape", propDef("string",
-                        "Shape to draw: box (rectangle border), highlight 
(background color on existing text like a marker pen), "
-                                                  + "underline (horizontal 
line), arrow-down, arrow-up, arrow-left, arrow-right, "
-                                                  + "text (draw text string at 
position)"),
+                        "box (border), highlight (marker-pen background), 
underline, arrow-down, arrow-up, "
+                                                  + "arrow-left, arrow-right, 
or text"),
                         "x", propDef("integer", "X coordinate (column) of the 
shape origin"),
                         "y", propDef("integer", "Y coordinate (row) of the 
shape origin"),
                         "width", propDef("integer", "Width of the shape (for 
box, highlight, underline)"),
@@ -334,11 +298,10 @@ class TuiToolRegistry {
                         "length", propDef("integer", "Length of arrows"),
                         "text", propDef("string", "Text content to draw (for 
text shape)"),
                         "color", propDef("string",
-                                "Color: red, green, blue, yellow, cyan, 
magenta, white, gray, black. Default: red for box/underline/arrow, yellow for 
highlight."),
-                        "duration",
-                        propDef("integer", "Auto-dismiss after this many 
seconds. If omitted, stays until cleared."),
-                        "append", propDef("boolean",
-                                "If true, add to existing drawing instead of 
replacing it. Default false.")),
+                                "red, green, blue, yellow, cyan, magenta, 
white, gray or black (default red, "
+                                                   + "yellow for highlight)"),
+                        "duration", propDef("integer", "Auto-dismiss after 
this many seconds; otherwise stays until cleared"),
+                        "append", propDef("boolean", "Add to the existing 
drawing instead of replacing it (default false)")),
                 List.of("shape", "x", "y"))));
 
         tools.add(toToolDef(toolDef(
@@ -395,14 +358,12 @@ class TuiToolRegistry {
                                                 + "If omitted, uses the active 
tab.")))));
         tools.add(toToolDef(toolDef(
                 "tui_get_status",
-                "Returns one top-level section of the integration's full 
status document, the same JSON the Camel "
-                                  + "CLI reads from 
~/.camel/<pid>-status.json. Use it for data the tabs do not show: "
-                                  + "'context' (name, version, state, uptime 
in millis, startTimestamp, statistics), "
-                                  + "'runtime' (pid, directory, java version), 
'healthChecks', 'properties', "
-                                  + "'main-configuration', 'routeController', 
'services', 'transformers', 'rests', "
-                                  + "'consumers', 'producers', 'endpoints', 
'dataSources', 'memory', 'threads', 'gc', "
-                                  + "'classLoading', 'trace', 'events'. Pass 
section='sections' to list what the "
-                                  + "document contains. Sections can be large, 
so request only the one you need.",
+                "One top-level section of the integration's full status 
document (~/.camel/<pid>-status.json). "
+                                  + "Use it for data no tab shows: context 
(name, version, state, uptime millis, "
+                                  + "startTimestamp, statistics), runtime 
(pid, directory, java), healthChecks, "
+                                  + "properties, main-configuration, 
routeController, services, transformers, rests, "
+                                  + "consumers, producers, endpoints, 
dataSources, memory, threads, gc, classLoading, "
+                                  + "trace, events. section='sections' lists 
them. Request only the section you need.",
                 Map.of("section", propDef("string",
                         "Top-level section name, or 'sections' to list the 
available names"),
                         "pid", propDef("string",
@@ -609,26 +570,17 @@ class TuiToolRegistry {
 
         tools.add(toToolDef(toolDef(
                 "tui_catalog_doc",
-                "Get documentation for a Camel catalog artifact (component, 
data format, language, EIP) "
-                                   + "including description, options, and 
Maven coordinates. "
-                                   + "Use optionsFilter to search options by 
keyword (e.g., 'security', 'ssl', 'timeout'). "
-                                   + "This enables queries like 'what options 
are there on kafka about security'. "
-                                   + "Set includeDoc=true to get the full 
AsciiDoc documentation for deep-dive questions. "
-                                   + "Uses the Camel version from the selected 
integration.",
-                Map.of("name", propDef("string",
-                        "Artifact name (e.g., kafka, json-jackson, simple, 
timer, choice, split)"),
+                "Camel catalog documentation for a component, data format, 
language or EIP: description, options "
+                                   + "and Maven coordinates, for the Camel 
version of the selected integration. "
+                                   + "Use optionsFilter for questions like 
'which kafka options are about security'.",
+                Map.of("name", propDef("string", "Artifact name, e.g. kafka, 
json-jackson, simple, timer, choice, split"),
                         "kind", propDef("string",
-                                "Artifact kind: component, dataformat, 
language, or eip. "
-                                                  + "If omitted, auto-detects 
by trying component first, then dataformat, then language, then eip."),
-                        "includeOptions", propDef("boolean",
-                                "Whether to include configuration options in 
the response (default: true). "
-                                                             + "Set to false 
for a lightweight response with just metadata."),
+                                "component, dataformat, language or eip; 
auto-detected in that order when omitted"),
+                        "includeOptions", propDef("boolean", "Include the 
configuration options (default true)"),
                         "includeDoc", propDef("boolean",
-                                "Whether to include the full AsciiDoc 
documentation text in the response (default: false). "
-                                                         + "Useful for 
deep-dive questions about usage, examples, and configuration patterns."),
+                                "Include the full AsciiDoc page for usage 
examples and patterns (default false)"),
                         "optionsFilter", propDef("string",
-                                "Filter options by keyword in name or 
description (case-insensitive substring match). "
-                                                           + "Only used when 
includeOptions is true.")),
+                                "Case-insensitive keyword to match in option 
names or descriptions")),
                 List.of("name"))));
 
         tools.add(toToolDef(toolDef(
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelHistoryCompactionTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelHistoryCompactionTest.java
new file mode 100644
index 000000000000..a5e842baed3e
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelHistoryCompactionTest.java
@@ -0,0 +1,106 @@
+/*
+ * 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.util.ArrayList;
+import java.util.List;
+
+import org.apache.camel.dsl.jbang.core.commands.LlmClient;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class AiPanelHistoryCompactionTest {
+
+    private static final String BIG = "x".repeat(5000);
+
+    /** One question answered with a tool call: user, assistant(tool call), 
tool result, assistant(answer). */
+    private static List<LlmClient.Message> turn(String question, String 
toolResult) {
+        List<LlmClient.Message> turn = new ArrayList<>();
+        turn.add(LlmClient.Message.user(question));
+        turn.add(LlmClient.Message.assistantWithToolCalls(null,
+                List.of(new LlmClient.ToolCall("call-" + question, 
"tui_get_log", new JsonObject()))));
+        turn.add(LlmClient.Message.toolResults(List.of(new 
LlmClient.ToolResult("call-" + question, toolResult))));
+        turn.add(LlmClient.Message.assistantWithToolCalls("answer to " + 
question, List.of()));
+        return turn;
+    }
+
+    private static String toolResultContent(LlmClient.Message message) {
+        return message.toolResults().get(0).content();
+    }
+
+    @Test
+    void truncatesOversizedToolResultsButKeepsShortOnes() {
+        String small = "ok";
+        assertSame(small, AiPanel.truncateToolResult(small));
+
+        String huge = "y".repeat(AiPanel.MAX_TOOL_RESULT_CHARS + 1234);
+        String truncated = AiPanel.truncateToolResult(huge);
+
+        
assertTrue(truncated.startsWith("y".repeat(AiPanel.MAX_TOOL_RESULT_CHARS)));
+        assertTrue(truncated.contains("[truncated, 1234 more characters"));
+    }
+
+    @Test
+    void compactsToolResultsOfOlderTurnsButKeepsThePreviousTurnIntact() {
+        List<LlmClient.Message> history = new ArrayList<>();
+        history.addAll(turn("q1", BIG));
+        history.addAll(turn("q2", BIG));
+        history.addAll(turn("q3", BIG));
+
+        AiPanel.compactHistory(history, 20, 400);
+
+        assertEquals(12, history.size());
+        String first = toolResultContent(history.get(2));
+        assertTrue(first.startsWith("x".repeat(400)));
+        assertTrue(first.contains("[earlier result compacted"));
+        // the previous turn (q2) and the current turn (q3) keep their full 
results
+        assertEquals(BIG, toolResultContent(history.get(6)));
+        assertEquals(BIG, toolResultContent(history.get(10)));
+        // structure is untouched: the tool call ids still match their results
+        assertEquals("call-q1", 
history.get(2).toolResults().get(0).toolCallId());
+    }
+
+    @Test
+    void dropsWholeOldestTurnsBeyondTheLimit() {
+        List<LlmClient.Message> history = new ArrayList<>();
+        for (int i = 1; i <= 5; i++) {
+            history.addAll(turn("q" + i, "r" + i));
+        }
+
+        AiPanel.compactHistory(history, 3, 400);
+
+        assertEquals(12, history.size());
+        assertEquals("q3", history.get(0).content());
+        assertEquals("user", history.get(0).role());
+        assertEquals("answer to q5", history.get(11).content());
+    }
+
+    @Test
+    void singleTurnAndEmptyHistoryAreLeftAlone() {
+        List<LlmClient.Message> history = new ArrayList<>(turn("q1", BIG));
+
+        AiPanel.compactHistory(history, 20, 400);
+        AiPanel.compactHistory(new ArrayList<>(), 20, 400);
+        AiPanel.compactHistory(null, 20, 400);
+
+        assertEquals(BIG, toolResultContent(history.get(2)));
+    }
+}

Reply via email to