diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc index 7ef06c41ef641..ac2c78c22f4a7 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc @@ -792,6 +792,7 @@ A question can span several lines: *Ctrl+N* starts a new line (terminals deliver The same guidance, together with the provider that is currently detected, is available inside the TUI via *F2* -> _AI & MCP_ -> _Setup AI_. Use *F2* -> _Settings_ to pin a provider, model or base URL (`camel.tui.ai.provider`, `camel.tui.ai.model`, `camel.tui.ai.url`) regardless of the environment. +`camel.tui.ai.acp.command` sets the command line of the custom ACP agent (`acp:custom`). ==== Using Ollama (local, no API key) @@ -1018,6 +1019,66 @@ prompt was processed again. For OpenAI, Anthropic and Gemini it prints the numbe tokens the provider reported. Use it to check that follow-up questions are cheap before blaming the model for being slow. +=== Using a coding agent (ACP) + +Instead of talking to a model directly, the AI panel can hand the conversation to an external coding agent that +speaks the https://agentclientprotocol.com[Agent Client Protocol] (ACP). The TUI starts the agent as a subprocess, +gives it the TUI's own MCP server, and shows the agent's answer, tool calls and questions in the panel. The agent +drives the TUI through the same `tui_*` tools that external MCP clients use, and can also read and edit your +route sources with its own tools. + +Press *Ctrl+P* in the AI panel and pick one of the agents: + +[options="header"] +|=== +| Provider | Command the TUI runs | Before the first question +| `acp:claude` | `npx -y @agentclientprotocol/claude-agent-acp` | log in with the `claude` CLI, or set `ANTHROPIC_API_KEY` +| `acp:codex` | `npx -y @agentclientprotocol/codex-acp` | `codex login`, or set `OPENAI_API_KEY` +| `acp:bob` | `bob acp` | set `BOBSHELL_API_KEY`, or run `bob` once to sign in +| `acp:qwen` | `qwen --acp` | set `OPENAI_API_KEY` and `OPENAI_BASE_URL` +| `acp:opencode` | `opencode acp` | `opencode auth login` +| `acp:dsh` | `npx -y @deepseek-ai/dsh --profile acp` | configure the model key in DeepSeek Harness (developer preview) +| `acp:custom` | the value of `camel.tui.ai.acp.command` | depends on the agent +|=== + +The `npx` entries need Node.js 22 or newer (the Claude adapter requires it). The agent starts with your first +question; the first start can take a while when `npx` has to download the adapter. Once the session is open the +panel shows a two-row header with the agent's coloured glyph, the agent's name and version, the session id, the +working directory and the number of commands it advertises. Use *F2* -> _Settings_ to make an +agent the default provider or to set the custom command (a plain command line split on whitespace, no quoting). + +The MCP server is started automatically on a random localhost port when an agent needs it, so `--mcp` is not +required. *F2* -> _MCP Info_ shows the port and the tool calls the agent makes. Like the server started with +`--mcp`, it is bound to `127.0.0.1` with no authentication, and it rejects requests that carry an `Origin` +header (so a web page cannot reach it) or that are not JSON. + +Permissions: calls to the TUI's read-only tools (the `tui_get_*` tools, catalog documentation, locate, validate) +are approved without asking. A tool that changes anything, such as `tui_control`, `tui_send_message` or +`tui_execute_sql`, opens the same popup as any other request; answering "Always allow" makes it a one-time +question per tool. For anything else the agent wants to do, +such as editing a file or running a command, a popup shows the agent's options; *Enter* selects, *Esc* rejects +that one call and lets the turn continue, and *Ctrl+C* cancels the whole turn. +"Always allow" choices are remembered by the agent for the session. The model, reasoning settings and any +"always allow" rules are configured in the agent, not in the TUI; `/model` only reports the agent in use. + +The agent's own slash commands, for example Claude Code skills, appear as `/agent:` in the `/` completion +hints once the session is open; `/agent:` alone lists them. `/agent: …` always reaches the agent, even when +the name is also a panel command (`/agent:clear` clears the agent's context, `/clear` the panel). Anything else +starting with `/` that is not a panel command is sent to the agent as is. + +The panel's own `/retry` resends the last question (or `/agent:` command) to the agent. `/context` shows the agent, +its session, working directory, MCP server and the size of the preamble instead of a model history, and `/prompt` +shows that preamble, sent once per session ahead of the first prompt. `/compact` and `/tools` do not apply while an +agent is selected: the agent manages its own history and reaches the camel-tui tools through the MCP server; the panel +says so and points to `/agent:compact` when the agent offers that command. + +If the agent asks for authentication and can sign you in itself, the TUI triggers that flow once; otherwise the +panel shows the login command from the table above. + +NOTE: Gemini CLI, Google Antigravity and Pi are not offered as presets. Gemini CLI stopped serving personal Google +accounts in June 2026, Antigravity's CLI has no ACP mode yet, and Pi's community adapter does not pass MCP servers +through. Any ACP agent can still be tried through `acp:custom`. + === Connecting an AI Agent To connect Claude Code to the TUI, add the MCP server to your project configuration diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpAgentClient.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpAgentClient.java new file mode 100644 index 0000000000000..d3466bdc601f1 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpAgentClient.java @@ -0,0 +1,687 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; + +import org.apache.camel.dsl.jbang.core.common.VersionHelper; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; + +/** + * Minimal Agent Client Protocol (ACP) version 1 client: newline-delimited JSON-RPC 2.0 over an agent process's stdin + * and stdout. Implements only what the AI panel needs: initialize, authenticate, session/new, session/prompt, + * session/cancel, the session/update notification and the session/request_permission callback. Every other + * agent-initiated request is answered with "method not found". + */ +final class AcpAgentClient implements AutoCloseable { + + static final int PROTOCOL_VERSION = 1; + static final int AUTH_REQUIRED = -32000; + static final int METHOD_NOT_FOUND = -32601; + /** Local error codes (never sent on the wire). */ + static final int CONNECTION = -1; + static final int TIMEOUT = -2; + static final int INTERRUPTED = -3; + private static final int STDERR_TAIL_LINES = 50; + /** How long the next prompt waits for the response the agent still owes the turn cancelled before it. */ + private static final Duration CANCELLED_TURN_GRACE = Duration.ofSeconds(10); + + /** Receives session/update notifications during a prompt turn. Called on the reader thread. */ + interface Listener { + void onTextChunk(String text); + + void onToolCall(String toolCallId, String title, String kind, JsonObject rawInput); + + void onToolCallUpdate(String toolCallId, String status, String contentText); + + void onUsage(long used, long size); + } + + /** Answers session/request_permission. Returns the chosen optionId, or null to answer "cancelled". */ + interface PermissionHandler { + String decide(JsonObject toolCall, List options); + } + + record AuthMethod(String id, String name, String description, String type) { + } + + record AgentInfo(int protocolVersion, String name, String version, boolean httpMcp, List authMethods) { + String label() { + if (name == null) { + return "ACP agent"; + } + return version == null ? name : name + " " + version; + } + } + + /** A slash command advertised by the agent through {@code available_commands_update}. */ + record AgentCommand(String name, String description, String hint) { + } + + /** A request already written to the agent, with the future its response completes. */ + private record Sent(long id, CompletableFuture future) { + } + + static final class AcpException extends RuntimeException { + private final int code; + + AcpException(int code, String message) { + super(message); + this.code = code; + } + + int code() { + return code; + } + } + + private final BufferedReader reader; + private final Writer writer; + private final Process process; + private final Consumer diagnostics; + private final Deque stderrTail = new ArrayDeque<>(); + private final Map> pending = new ConcurrentHashMap<>(); + private final AtomicLong nextId = new AtomicLong(1); + private final ExecutorService requestExecutor = Executors.newSingleThreadExecutor(r -> daemon(r, "tui-acp-requests")); + private final CountDownLatch streamClosed = new CountDownLatch(1); + private volatile Listener listener; + private volatile String listenerSession; + private volatile PermissionHandler permissionHandler = (toolCall, options) -> null; + private volatile boolean closed; + private volatile Thread stderrThread; + private volatile AcpException exitFailure; + private volatile List availableCommands = List.of(); + /** The session {@link #availableCommands} came from, tracked because the update may precede session/new's reply. */ + private volatile String commandsSession; + private volatile String currentSession; + private volatile CompletableFuture cancelledTurn; + + AcpAgentClient(InputStream fromAgent, OutputStream toAgent, Consumer diagnostics) { + this(fromAgent, toAgent, null, diagnostics); + } + + private AcpAgentClient(InputStream fromAgent, OutputStream toAgent, Process process, Consumer diagnostics) { + this.reader = new BufferedReader(new InputStreamReader(fromAgent, StandardCharsets.UTF_8)); + this.writer = new BufferedWriter(new OutputStreamWriter(toAgent, StandardCharsets.UTF_8)); + this.process = process; + this.diagnostics = diagnostics != null ? diagnostics : s -> { + }; + } + + /** Starts {@code command} in {@code cwd}, wires its stdin/stdout to a client and starts reading. */ + static AcpAgentClient spawn(List command, Path cwd, Consumer diagnostics) throws IOException { + ProcessBuilder builder = new ProcessBuilder(command); + builder.directory(cwd.toFile()); + Process process = builder.start(); + AcpAgentClient client = new AcpAgentClient(process.getInputStream(), process.getOutputStream(), process, diagnostics); + client.drainStderr(process.getErrorStream()); + client.start(); + return client; + } + + private void drainStderr(InputStream err) { + stderrThread = daemon(() -> { + try (BufferedReader r = new BufferedReader(new InputStreamReader(err, StandardCharsets.UTF_8))) { + String line; + while ((line = r.readLine()) != null) { + synchronized (stderrTail) { + stderrTail.addLast(line); + if (stderrTail.size() > STDERR_TAIL_LINES) { + stderrTail.removeFirst(); + } + } + } + } catch (IOException ignored) { + // process gone + } + }, "tui-acp-stderr"); + stderrThread.start(); + } + + void start() { + daemon(this::readLoop, "tui-acp-reader").start(); + } + + void setPermissionHandler(PermissionHandler handler) { + this.permissionHandler = handler != null ? handler : (toolCall, options) -> null; + } + + boolean isAlive() { + return !closed && (process == null || process.isAlive()); + } + + String stderrTail() { + synchronized (stderrTail) { + return String.join("\n", stderrTail); + } + } + + /** The agent's current slash commands (Claude Code's include its skills); empty until the agent advertises them. */ + List availableCommands() { + return availableCommands; + } + + // ---- ACP calls ---- + + AgentInfo initialize(Duration timeout) { + JsonObject fs = new JsonObject(); + fs.put("readTextFile", false); + fs.put("writeTextFile", false); + JsonObject capabilities = new JsonObject(); + capabilities.put("fs", fs); + capabilities.put("terminal", false); + JsonObject clientInfo = new JsonObject(); + clientInfo.put("name", "camel-tui"); + clientInfo.put("title", "Apache Camel TUI"); + clientInfo.put("version", VersionHelper.getJBangVersion()); + JsonObject params = new JsonObject(); + params.put("protocolVersion", PROTOCOL_VERSION); + params.put("clientCapabilities", capabilities); + params.put("clientInfo", clientInfo); + JsonObject result = request("initialize", params, timeout); + + int version = result.getIntegerOrDefault("protocolVersion", -1); + JsonObject agentCapabilities = result.getJsonObject("agentCapabilities"); + JsonObject mcp = agentCapabilities != null ? agentCapabilities.getJsonObject("mcpCapabilities") : null; + boolean http = mcp != null && mcp.getBooleanOrDefault("http", false); + JsonObject agentInfo = result.getJsonObject("agentInfo"); + List methods = new ArrayList<>(); + JsonArray authMethods = result.getJsonArray("authMethods"); + if (authMethods != null) { + for (Object o : authMethods) { + if (o instanceof JsonObject m) { + JsonObject meta = m.get("_meta") instanceof JsonObject mo ? mo : null; + String type = meta != null && meta.getString("type") != null ? meta.getString("type") : m.getString("type"); + methods.add(new AuthMethod(m.getString("id"), m.getString("name"), m.getString("description"), type)); + } + } + } + return new AgentInfo( + version, + agentInfo != null ? agentInfo.getString("name") : null, + agentInfo != null ? agentInfo.getString("version") : null, + http, List.copyOf(methods)); + } + + // ---- ACP calls (continued) ---- + + void authenticate(String methodId, Duration timeout) { + JsonObject params = new JsonObject(); + params.put("methodId", methodId); + request("authenticate", params, timeout); + } + + String newSession(Path cwd, String mcpUrl, Duration timeout) { + JsonObject server = new JsonObject(); + server.put("type", "http"); + server.put("name", "camel-tui"); + server.put("url", mcpUrl); + server.put("headers", new JsonArray()); + JsonArray servers = new JsonArray(); + servers.add(server); + JsonObject params = new JsonObject(); + params.put("cwd", cwd.toAbsolutePath().toString()); + params.put("mcpServers", servers); + // the agent may advertise the new session's commands before it answers, so the old list goes first + currentSession = null; + commandsSession = null; + availableCommands = List.of(); + // an old session's owed response must not delay this session's first question; its updates are filtered anyway + cancelledTurn = null; + JsonObject result = request("session/new", params, timeout); + String sessionId = result.getString("sessionId"); + if (sessionId == null) { + throw new AcpException(CONNECTION, "session/new returned no sessionId"); + } + currentSession = sessionId; + if (commandsSession != null && !commandsSession.equals(sessionId)) { + availableCommands = List.of(); + } + return sessionId; + } + + /** + * Sends one user message and blocks until the agent ends the turn. Updates are delivered to {@code listener} on the + * reader thread while this call blocks. Interrupting the calling thread sends session/cancel and returns + * "cancelled" without waiting for the agent; the calling thread's interrupt flag stays set in that case. Only one + * prompt may be in flight per client, because the client keeps a single listener for the turn. + */ + String prompt(String sessionId, String text, Listener listener) { + Sent sent = null; + try { + awaitCancelledTurn(); + this.listenerSession = sessionId; + this.listener = listener; + JsonObject block = new JsonObject(); + block.put("type", "text"); + block.put("text", text); + JsonArray prompt = new JsonArray(); + prompt.add(block); + JsonObject params = new JsonObject(); + params.put("sessionId", sessionId); + params.put("prompt", prompt); + sent = sendRequest("session/prompt", params); + JsonObject result = await(sent, "session/prompt", null); + return result.getStringOrDefault("stopReason", "end_turn"); + } catch (AcpException e) { + if (e.code() == INTERRUPTED) { + if (sent != null) { + cancelledTurn = sent.future(); + } + cancel(sessionId); + return "cancelled"; + } + throw e; + } finally { + this.listener = null; + this.listenerSession = null; + } + } + + /** + * Waits for the agent to answer the prompt cancelled last: ACP lets it keep streaming until that response, and + * those late updates must not reach the next turn's listener. Bounded, so a stuck agent cannot block the panel; on + * timeout the old turn is forgotten and the new prompt goes ahead. + */ + private void awaitCancelledTurn() { + CompletableFuture turn = cancelledTurn; + if (turn == null) { + return; + } + try { + turn.get(CANCELLED_TURN_GRACE.toMillis(), TimeUnit.MILLISECONDS); + } catch (ExecutionException e) { + // the agent died or the stream closed: the next request reports that + } catch (TimeoutException e) { + diagnostics.accept("The cancelled turn did not finish within " + CANCELLED_TURN_GRACE.toSeconds() + "s"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AcpException(INTERRUPTED, "session/prompt interrupted"); + } + cancelledTurn = null; + } + + void cancel(String sessionId) { + JsonObject params = new JsonObject(); + params.put("sessionId", sessionId); + try { + notify("session/cancel", params); + } catch (AcpException e) { + diagnostics.accept("Cannot send session/cancel: " + e.getMessage()); + } + } + + @Override + public void close() { + closed = true; + currentSession = null; + requestExecutor.shutdownNow(); + synchronized (writer) { + try { + writer.close(); + } catch (IOException ignored) { + // closing anyway + } + } + if (process != null) { + process.destroy(); + try { + if (!process.waitFor(5, TimeUnit.SECONDS)) { + process.destroyForcibly(); + } + } catch (InterruptedException e) { + process.destroyForcibly(); + Thread.currentThread().interrupt(); + } + } + } + + // ---- JSON-RPC plumbing ---- + + private void awaitStreamClosed() { + try { + streamClosed.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + JsonObject request(String method, JsonObject params, Duration timeout) { + return await(sendRequest(method, params), method, timeout); + } + + private Sent sendRequest(String method, JsonObject params) { + long id = nextId.getAndIncrement(); + CompletableFuture future = new CompletableFuture<>(); + pending.put(id, future); + JsonObject msg = new JsonObject(); + msg.put("jsonrpc", "2.0"); + msg.put("id", id); + msg.put("method", method); + if (params != null) { + msg.put("params", params); + } + if (closed) { + pending.remove(id); + throw exitFailure != null ? exitFailure : new AcpException(CONNECTION, "agent exited"); + } + try { + send(msg); + } catch (AcpException e) { + pending.remove(id); + if (process != null) { + awaitStreamClosed(); + if (exitFailure != null) { + throw exitFailure; + } + } + throw e; + } + return new Sent(id, future); + } + + private JsonObject await(Sent sent, String method, Duration timeout) { + CompletableFuture future = sent.future(); + try { + return timeout == null ? future.get() : future.get(timeout.toMillis(), TimeUnit.MILLISECONDS); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + throw cause instanceof AcpException acp ? acp : new AcpException(CONNECTION, String.valueOf(cause)); + } catch (TimeoutException e) { + pending.remove(sent.id()); + throw new AcpException(TIMEOUT, method + " timed out after " + timeout.toSeconds() + "s"); + } catch (InterruptedException e) { + // the entry stays pending on purpose: a late response completes an unread future, which is what the next + // prompt waits for before installing its own listener + Thread.currentThread().interrupt(); + throw new AcpException(INTERRUPTED, method + " interrupted"); + } + } + + void notify(String method, JsonObject params) { + JsonObject msg = new JsonObject(); + msg.put("jsonrpc", "2.0"); + msg.put("method", method); + if (params != null) { + msg.put("params", params); + } + send(msg); + } + + private void send(JsonObject msg) { + String line = Jsoner.serialize(msg); + synchronized (writer) { + try { + writer.write(line); + writer.write('\n'); + writer.flush(); + } catch (IOException e) { + throw new AcpException(CONNECTION, "Cannot write to agent: " + e.getMessage()); + } + } + } + + private void readLoop() { + try { + String line; + while ((line = reader.readLine()) != null) { + if (line.isBlank()) { + continue; + } + JsonObject msg = Jsoner.deserialize(line, (JsonObject) null); + if (msg == null || !msg.containsKey("jsonrpc")) { + diagnostics.accept("Ignoring line that is not a JSON-RPC object: " + abbreviate(line)); + continue; + } + try { + dispatch(msg); + } catch (RuntimeException e) { + diagnostics.accept("Cannot handle message from agent: " + e); + } + } + } catch (IOException e) { + if (!closed) { + diagnostics.accept("Agent stream error: " + e.getMessage()); + } + } finally { + onStreamClosed(); + } + } + + private void dispatch(JsonObject msg) { + Object id = msg.get("id"); + String method = msg.getString("method"); + if (method == null && id != null) { + if (!(id instanceof Number number)) { + return; + } + CompletableFuture future = pending.remove(number.longValue()); + if (future == null) { + return; + } + JsonObject error = msg.getJsonObject("error"); + if (error != null) { + future.completeExceptionally(new AcpException( + error.getIntegerOrDefault("code", CONNECTION), error.getStringOrDefault("message", "error"))); + } else { + JsonObject result = msg.getJsonObject("result"); + future.complete(result != null ? result : new JsonObject()); + } + } else if (method != null && id != null) { + JsonObject params = msg.getJsonObject("params"); + requestExecutor.execute(() -> handleRequest(id, method, params)); + } else if (method != null) { + handleNotification(method, msg.getJsonObject("params")); + } + } + + private void handleRequest(Object id, String method, JsonObject params) { + JsonObject response = new JsonObject(); + response.put("jsonrpc", "2.0"); + response.put("id", id); + if ("session/request_permission".equals(method) && params != null) { + JsonObject toolCall = params.getJsonObject("toolCall"); + List options = new ArrayList<>(); + JsonArray array = params.getJsonArray("options"); + if (array != null) { + for (Object o : array) { + if (o instanceof JsonObject option) { + options.add(option); + } + } + } + String optionId; + try { + optionId = permissionHandler.decide(toolCall != null ? toolCall : new JsonObject(), options); + } catch (RuntimeException e) { + diagnostics.accept("Permission handler failed: " + e.getMessage()); + optionId = null; + } + JsonObject outcome = new JsonObject(); + if (optionId == null) { + outcome.put("outcome", "cancelled"); + } else { + outcome.put("outcome", "selected"); + outcome.put("optionId", optionId); + } + JsonObject result = new JsonObject(); + result.put("outcome", outcome); + response.put("result", result); + } else { + JsonObject error = new JsonObject(); + error.put("code", METHOD_NOT_FOUND); + error.put("message", "Method not found: " + method); + response.put("error", error); + } + try { + send(response); + } catch (AcpException e) { + diagnostics.accept("Cannot answer " + method + ": " + e.getMessage()); + } + } + + private void handleNotification(String method, JsonObject params) { + if (!"session/update".equals(method) || params == null) { + return; + } + JsonObject update = params.getJsonObject("update"); + if (update == null) { + return; + } + String kind = update.getStringOrDefault("sessionUpdate", ""); + if ("available_commands_update".equals(kind)) { + String current = currentSession; + String updateSession = params.getString("sessionId"); + if (current != null && updateSession != null && !current.equals(updateSession)) { + // an update from a session abandoned (for example after /clear) must not reach the new session + return; + } + commandsSession = updateSession; + List commands = new ArrayList<>(); + if (update.get("availableCommands") instanceof JsonArray array) { + for (Object o : array) { + if (o instanceof JsonObject c && c.getString("name") != null) { + JsonObject input = c.get("input") instanceof JsonObject in ? in : null; + commands.add(new AgentCommand( + c.getString("name"), c.getStringOrDefault("description", ""), + input != null ? input.getString("hint") : null)); + } + } + } + availableCommands = List.copyOf(commands); + return; + } + Listener target = listener; + String session = listenerSession; + if (target == null) { + return; + } + String updateSession = params.getString("sessionId"); + if (session != null && updateSession != null && !session.equals(updateSession)) { + // an update from an abandoned session (for example after /clear) must not reach the current turn + return; + } + switch (kind) { + case "agent_message_chunk" -> { + JsonObject content = update.getJsonObject("content"); + if (content != null && "text".equals(content.getString("type"))) { + target.onTextChunk(content.getStringOrDefault("text", "")); + } + } + case "tool_call" -> { + Object raw = update.get("rawInput"); + target.onToolCall(update.getString("toolCallId"), update.getStringOrDefault("title", "tool"), + update.getStringOrDefault("kind", "other"), raw instanceof JsonObject jo ? jo : null); + } + case "tool_call_update" -> target.onToolCallUpdate(update.getString("toolCallId"), + update.getStringOrDefault("status", ""), textOf(update.get("content"))); + case "usage_update" -> target.onUsage(update.getLongOrDefault("used", 0), update.getLongOrDefault("size", 0)); + default -> { + // thoughts, plans, mode/command/config updates and unknown kinds are ignored on purpose + } + } + } + + /** Concatenates the text of {@code {type:"content", content:{type:"text", text}}} blocks; null when none. */ + private static String textOf(Object content) { + if (!(content instanceof JsonArray blocks)) { + return null; + } + StringBuilder sb = new StringBuilder(); + for (Object o : blocks) { + if (o instanceof JsonObject block && block.get("content") instanceof JsonObject inner + && "text".equals(inner.getString("type"))) { + sb.append(inner.getStringOrDefault("text", "")); + } + } + return sb.isEmpty() ? null : sb.toString(); + } + + private void onStreamClosed() { + try { + Thread drain = stderrThread; + if (drain != null) { + try { + drain.join(2_000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + String reason = "agent exited"; + if (process != null) { + try { + process.waitFor(2, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + if (!process.isAlive()) { + reason = "agent exited with code " + process.exitValue(); + } + } + String tail = stderrTail(); + if (!tail.isBlank()) { + reason += "\n" + tail; + } + AcpException failure = new AcpException(CONNECTION, reason); + exitFailure = failure; + closed = true; + for (CompletableFuture future : pending.values()) { + future.completeExceptionally(failure); + } + pending.clear(); + } finally { + streamClosed.countDown(); + } + } + + private static String abbreviate(String line) { + return line.length() <= 120 ? line : line.substring(0, 117) + "..."; + } + + private static Thread daemon(Runnable task, String name) { + Thread t = new Thread(task, name); + t.setDaemon(true); + return t; + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpHeaderStrip.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpHeaderStrip.java new file mode 100644 index 0000000000000..c015fe50709a3 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpHeaderStrip.java @@ -0,0 +1,77 @@ +/* + * 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.nio.file.Path; + +import dev.tamboui.layout.Rect; +import dev.tamboui.style.Color; +import dev.tamboui.style.Style; +import dev.tamboui.terminal.Frame; +import dev.tamboui.text.Line; +import dev.tamboui.text.Span; +import dev.tamboui.widgets.paragraph.Paragraph; + +/** + * Two-row header shown under the AI panel title while an ACP session is open: the agent's coloured glyph, the preset + * and agent labels, and a dimmed line with session, working directory and command count. ACP agents run headless and + * never draw a start screen of their own. + */ +final class AcpHeaderStrip { + + static final int ROWS = 2; + + record Model(String presetLabel, String glyph, Color color, String agentLabel, String sessionId, Path cwd, + int commandCount) { + } + + void render(Frame frame, Rect area, Model model) { + if (area.height() < ROWS || area.width() < 20) { + return; + } + Style accent = Style.EMPTY.fg(model.color()).bold(); + String title = model.presetLabel() + " · " + model.agentLabel(); + Line first = Line.from(Span.styled(model.glyph() + " ", accent), Span.styled(title, accent)); + Line second = Line.from(Span.styled(" " + metaLine(model), Style.EMPTY.dim())); + frame.renderWidget(Paragraph.from(first), new Rect(area.x(), area.y(), area.width(), 1)); + frame.renderWidget(Paragraph.from(second), new Rect(area.x(), area.y() + 1, area.width(), 1)); + } + + static String metaLine(Model model) { + String session = model.sessionId() == null ? "?" : model.sessionId(); + if (session.length() > 8) { + session = session.substring(0, 8); + } + String commands = model.commandCount() == 0 + ? "no commands yet" + : model.commandCount() + (model.commandCount() == 1 ? " command" : " commands"); + return "session " + session + " · " + homeRelative(model.cwd()) + " · " + commands + " · /agent: lists them"; + } + + static String homeRelative(Path path) { + if (path == null) { + return "?"; + } + String home = System.getProperty("user.home"); + String value = path.toAbsolutePath().toString(); + if (home != null && !home.isBlank() && value.startsWith(home)) { + return "~" + value.substring(home.length()); + } + return value; + } + +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpPermissionPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpPermissionPopup.java new file mode 100644 index 0000000000000..aeeb893f63bb6 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpPermissionPopup.java @@ -0,0 +1,207 @@ +/* + * 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 dev.tamboui.layout.Constraint; +import dev.tamboui.layout.Layout; +import dev.tamboui.layout.Rect; +import dev.tamboui.style.Style; +import dev.tamboui.terminal.Frame; +import dev.tamboui.text.Line; +import dev.tamboui.text.Span; +import dev.tamboui.text.Text; +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.tui.event.MouseEvent; +import dev.tamboui.tui.event.MouseEventKind; +import dev.tamboui.widgets.Clear; +import dev.tamboui.widgets.block.Block; +import dev.tamboui.widgets.block.BorderType; +import dev.tamboui.widgets.block.Borders; +import dev.tamboui.widgets.list.ListItem; +import dev.tamboui.widgets.list.ListState; +import dev.tamboui.widgets.list.ListWidget; +import dev.tamboui.widgets.list.ScrollMode; +import dev.tamboui.widgets.paragraph.Paragraph; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; + +/** + * Asks the user to answer an ACP {@code session/request_permission}: shows the tool title, kind and the first lines of + * its raw input, then the options exactly as the agent sent them. Enter selects, Esc picks a reject-once option when + * the agent offers one and otherwise answers "cancelled". + */ +final class AcpPermissionPopup { + + private static final int MAX_INPUT_LINES = 8; + + /** {@code optionId} is null when the request should be answered "cancelled". */ + record Decision(String optionId) { + } + + // open() is called from the ACP request thread while isVisible()/render()/handleKeyEvent() run on the TUI event + // thread; volatile so the write to visible (the last statement of open()) publishes the other fields set above it. + private volatile boolean visible; + private String title = ""; + private String kind = ""; + private List inputLines = List.of(); + private List options = List.of(); + private final ListState listState = new ListState(); + private Decision pending; + private Rect listRect; + + boolean isVisible() { + return visible; + } + + Rect listRectForTesting() { + return listRect; + } + + void open(JsonObject toolCall, List options) { + this.title = String.valueOf(toolCall.getStringOrDefault("title", "Use tool?")); + this.kind = String.valueOf(toolCall.getStringOrDefault("kind", "other")); + this.inputLines = formatInput(toolCall.get("rawInput")); + this.options = new ArrayList<>(options); + listState.selectFirst(); + pending = null; + visible = true; + } + + void close() { + visible = false; + } + + Decision consumeDecision() { + Decision decision = pending; + pending = null; + return decision; + } + + boolean handleMouseEvent(MouseEvent me) { + if (!visible) { + return false; + } + if (me.kind() == MouseEventKind.SCROLL_UP) { + handleKeyEvent(KeyEvent.ofKey(KeyCode.UP)); + } else if (me.kind() == MouseEventKind.SCROLL_DOWN) { + handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN)); + } else if (me.isClick() && listRect != null && listRect.contains(me.x(), me.y())) { + // only a click on an option row answers; a click elsewhere in the dialog must not grant anything. + // listRect is already the list's own content area (no border to skip), unlike the bordered popup + // rects TuiHelper.listItemAt expects everywhere else it is called, so the row is computed directly. + int idx = listState.offset() + (me.y() - listRect.y()); + if (idx >= 0 && idx < options.size()) { + listState.select(idx); + handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER)); + } + } + return true; + } + + void handleKeyEvent(KeyEvent ke) { + if (ke.isCancel()) { + pending = new Decision(firstOptionOfKind("reject_once")); + close(); + } else if (ke.isUp()) { + listState.selectPrevious(); + } else if (ke.isDown()) { + listState.selectNext(options.size()); + } else if (ke.isConfirm()) { + Integer selected = listState.selected(); + if (selected != null && selected < options.size()) { + pending = new Decision(options.get(selected).getString("optionId")); + close(); + } + } + } + + private String firstOptionOfKind(String optionKind) { + for (JsonObject option : options) { + if (optionKind.equals(option.getString("kind"))) { + return option.getString("optionId"); + } + } + return null; + } + + void render(Frame frame, Rect area) { + int headerRows = 2 + inputLines.size() + 1; + int popupW = Math.max(20, Math.min(80, area.width() - 4)); + int popupH = Math.max(6, Math.min(2 + headerRows + options.size(), area.height() - 2)); + Rect popup = DialogHelper.centered(area, popupW, popupH); + frame.renderWidget(Clear.INSTANCE, popup); + Block block = Block.builder() + .borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(" Permission ") + .build(); + frame.renderWidget(block, popup); + Rect inner = block.inner(popup); + // the choices come first: on a short screen the header is clipped (title first, so it survives longest) + int optionRows = Math.min(options.size(), Math.max(1, inner.height() - 1)); + int headerHeight = Math.max(0, Math.min(headerRows, inner.height() - optionRows)); + List parts = Layout.vertical() + .constraints(Constraint.length(headerHeight), Constraint.fill()) + .split(inner); + + List header = new ArrayList<>(); + header.add(Line.from(Span.styled(title, Style.EMPTY.bold()))); + header.add(Line.from(Span.styled("kind: " + kind, Style.EMPTY.dim()))); + for (String line : inputLines) { + header.add(Line.from(Span.styled(line, Style.EMPTY.dim()))); + } + header.add(Line.from(Span.styled("Allow the agent to run this tool?", Style.EMPTY))); + frame.renderWidget(Paragraph.builder().text(Text.from(header.toArray(Line[]::new))).build(), parts.get(0)); + + List items = new ArrayList<>(); + for (JsonObject option : options) { + items.add(ListItem.from(Line.from(Span.styled(" " + option.getStringOrDefault("name", "?"), Style.EMPTY)))); + } + ListWidget list = ListWidget.builder() + .items(items.toArray(ListItem[]::new)) + .scrollMode(ScrollMode.AUTO_SCROLL) + .highlightStyle(Theme.selectionBg()) + .highlightSymbol("") + .build(); + this.listRect = parts.get(1); + frame.renderStatefulWidget(list, parts.get(1), listState); + } + + void renderFooter(List spans) { + TuiHelper.hint(spans, "Enter", "select"); + TuiHelper.hint(spans, "Ctrl+C", "cancel turn"); + TuiHelper.hintLast(spans, "Esc", "reject"); + } + + private static List formatInput(Object rawInput) { + if (rawInput == null) { + return List.of(); + } + String[] lines = Jsoner.prettyPrint(Jsoner.serialize(rawInput)).split("\n"); + List out = new ArrayList<>(); + for (int i = 0; i < lines.length && i < MAX_INPUT_LINES; i++) { + out.add(lines[i]); + } + if (lines.length > MAX_INPUT_LINES) { + out.add("…"); + } + return out; + } +} 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 aa5087c567d11..02eee700d536a 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 @@ -19,12 +19,14 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Duration; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Comparator; import java.util.Deque; import java.util.HashMap; import java.util.LinkedHashMap; @@ -32,8 +34,11 @@ import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.Set; +import java.util.concurrent.Callable; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; @@ -190,6 +195,9 @@ record LogEntry(String timestamp, LogLevel level, String message, String detail) private long thinkingStartTime; private volatile String thinkingVerb; private volatile int sessionTotalTokens; + /** What the ACP agent last reported in its context window, and how big that window is (0 = not reported). */ + private volatile long acpContextUsed; + private volatile long acpContextSize; private volatile long sessionToolTimeMs; private volatile int sessionToolCalls; /** Per answered question: [aiMs, toolMs, toolCalls], in order, for the time chart in the usage view. */ @@ -250,6 +258,32 @@ public void printf(String format, Object... args) { private List providerChoicesForTesting; private boolean testingClientInjected; + // ACP backend: set when the selected provider is an external coding agent (provider id "acp:*"). The agent + // process is spawned lazily on the first prompt so a slow first npx download never freezes the UI. + private static final Duration ACP_INIT_TIMEOUT = Duration.ofSeconds(120); + private static final Duration ACP_SESSION_TIMEOUT = Duration.ofSeconds(60); + private static final Duration ACP_AUTH_TIMEOUT = Duration.ofSeconds(300); + private static final String TUI_TOOL_PREFIX = "mcp__camel-tui__"; + private static final Set FILE_OR_SHELL_KINDS = Set.of("edit", "delete", "move", "execute", "fetch"); + private static final String AGENT_COMMAND_PREFIX = "/agent:"; + private volatile AiProviderSelector.AcpPreset acpPreset; + private volatile AcpAgentClient acpClient; + private volatile AcpAgentClient.AgentInfo acpAgentInfo; + private volatile String acpSessionId; + private volatile boolean acpPreambleSent; + private final AcpHeaderStrip acpHeader = new AcpHeaderStrip(); + private String acpMcpUrl; + private Path acpCwd; + private AcpClientFactory acpClientFactory = this::spawnAcpAgent; + private Callable mcpUrlSupplier; + private final AcpPermissionPopup permissionPopup = new AcpPermissionPopup(); + private volatile CompletableFuture pendingPermission; + + /** Creates the client for a preset; replaced in tests with one backed by {@code FakeAcpAgent}. */ + interface AcpClientFactory { + AcpAgentClient create(AiProviderSelector.AcpPreset preset, Path cwd) throws IOException; + } + // MCP facade for TUI tool access from the AI panel private McpFacade mcpFacade; // /write: confirm (dialog per write), auto (the model may skip it with confirm=false) or live (the edit is replayed @@ -457,10 +491,26 @@ void addPendingNote(String note) { void destroy() { close(); stopAgentThread(); + closeAcpClient(); } private void initClient() { modelCompletionCache = null; + String provider = sessionProviderChoice != null + ? sessionProviderChoice.provider() : TuiSettings.load().getAiProvider(); + if (AiProviderSelector.isAcp(provider)) { + try { + TuiSettings settings = TuiSettings.load(); + acpPreset = providerSelector.acpPreset(provider, settings); + initError = null; + } catch (IllegalArgumentException e) { + acpPreset = null; + initError = e.getMessage(); + } + client = null; + return; + } + acpPreset = null; try { LlmClient created = LlmClient.create() .withTemperature(0.3) @@ -495,7 +545,26 @@ private void initClient() { private void applyProviderChoice(AiProviderSwitchPopup.ProviderChoice choice) { stopAgentThread(); + closeAcpClient(); sessionProviderChoice = choice; + if (AiProviderSelector.isAcp(choice.provider())) { + client = null; + initError = null; + try { + TuiSettings settings = TuiSettings.load(); + acpPreset = providerSelector.acpPreset(choice.provider(), settings); + } catch (IllegalArgumentException e) { + acpPreset = null; + sessionProviderChoice = null; + conversation.add(new ConversationEntry(AiRole.ERROR, e.getMessage())); + return; + } + conversation.add(new ConversationEntry( + AiRole.SYSTEM, + "Selected " + acpPreset.label() + ". The agent starts with your first question.")); + return; + } + acpPreset = null; if (testingClientInjected && client != null) { // Keep tests independent of the locally installed camel-jbang-core artifact. } else { @@ -514,6 +583,32 @@ private void applyProviderChoice(AiProviderSwitchPopup.ProviderChoice choice) { } } + private void closeAcpClient() { + AcpAgentClient agent = acpClient; + acpClient = null; + acpSessionId = null; + acpAgentInfo = null; + acpPreambleSent = false; + acpContextUsed = 0; + acpContextSize = 0; + if (agent != null) { + // Do not block the TUI event thread: close() destroys the agent process and waits up to 5 seconds for + // it to die. Nothing here observes the shutdown, so it runs on a short-lived daemon thread. + Thread closer = new Thread(agent::close, "tui-acp-close"); + closer.setDaemon(true); + closer.start(); + } + } + + private String acpLabel() { + AcpAgentClient.AgentInfo info = acpAgentInfo; + AiProviderSelector.AcpPreset preset = acpPreset; + if (info != null) { + return info.label(); + } + return preset != null ? preset.label() : "ACP agent"; + } + private String displayModel(AiProviderSwitchPopup.ProviderChoice choice) { return choice.model() == null || choice.model().isBlank() ? "auto" : choice.model(); } @@ -552,6 +647,15 @@ boolean handleMouseEvent(MouseEvent me) { copyChoice(copyPopup.consumePendingChoice()); return handled; } + if (permissionPopup.isVisible()) { + boolean handled = permissionPopup.handleMouseEvent(me); + AcpPermissionPopup.Decision decision = permissionPopup.consumeDecision(); + CompletableFuture pending = pendingPermission; + if (decision != null && pending != null) { + pending.complete(decision.optionId()); + } + return handled; + } if (!TuiHelper.contains(lastArea, me.x(), me.y())) { return false; } @@ -567,6 +671,21 @@ boolean handleMouseEvent(MouseEvent me) { } boolean handleKeyEvent(KeyEvent ke) { + if (permissionPopup.isVisible()) { + if (ke.isCtrlC()) { + interruptBusyOperation(); + return true; + } + permissionPopup.handleKeyEvent(ke); + AcpPermissionPopup.Decision decision = permissionPopup.consumeDecision(); + if (decision != null) { + CompletableFuture pending = pendingPermission; + if (pending != null) { + pending.complete(decision.optionId()); + } + } + return true; + } if (providerSwitchPopup.isVisible()) { providerSwitchPopup.handleKeyEvent(ke); AiProviderSwitchPopup.ProviderChoice choice = providerSwitchPopup.consumePendingChoice(); @@ -916,7 +1035,7 @@ private void handleTabCompletion(boolean backward) { currentToken = argument.token(); completionStart = argument.start(); } else { - names = slashCommands.completionsFor(text).stream() + names = slashCommands.completionsFor(text, agentCommandDescriptors()).stream() .map(AiSlashCommandRegistry.Descriptor::name) .toList(); currentToken = text.length() > 1 ? text.substring(1) : ""; @@ -1084,6 +1203,17 @@ private void replaceInputBuffer(String text) { } private void executeSlashCommand(String input) { + if (acpPreset != null && input.startsWith(AGENT_COMMAND_PREFIX)) { + String rest = input.substring(AGENT_COMMAND_PREFIX.length()).strip(); + if (rest.isEmpty()) { + conversation.add(new ConversationEntry(AiRole.SYSTEM, agentCommandListing())); + return; + } + // explicit escape: reaches the agent even when the name is also a panel command + submitQuestion(input); + return; + } + Optional parsed = slashCommands.parse(input); if (parsed.isPresent() && (thinking.get() || activeCliCommand != null)) { String name = parsed.get().descriptor().name(); @@ -1096,6 +1226,12 @@ private void executeSlashCommand(String input) { } } + if (parsed.isEmpty() && acpPreset != null) { + // not a panel command: hand it to the agent verbatim (its own slash commands and skills) + submitQuestion(input); + return; + } + AiSlashCommandRegistry.CommandResult result = slashCommands.execute(input, slashCommandContext); if (result.cliRequest() != null) { AiCliCommandExecutor.Request request = result.cliRequest(); @@ -1171,6 +1307,20 @@ private void interruptBusyOperation() { conversation.add(new ConversationEntry(AiRole.SYSTEM, "(command cancelled)")); } if (thinking.get()) { + CompletableFuture permission = pendingPermission; + if (permission != null) { + permission.complete(null); + permissionPopup.close(); + } + AcpAgentClient agent = acpClient; + String session = acpSessionId; + if (agent != null && session != null) { + // Do not block the TUI event thread: cancel() writes to the agent under the client's writer lock. + // prompt() sends the same notification on its own interrupt path; a duplicate cancel is harmless. + Thread canceller = new Thread(() -> agent.cancel(session), "tui-acp-cancel"); + canceller.setDaemon(true); + canceller.start(); + } stopAgentThread(); conversation.add(new ConversationEntry(AiRole.SYSTEM, "(cancelled)")); } @@ -1201,6 +1351,10 @@ private void awaitAgentThreadStop(Thread agent) { private void submitQuestion(String question) { stopAgentThread(); + if (acpPreset != null) { + submitAcpQuestion(question); + return; + } if (client == null) { conversation.add(new ConversationEntry( AiRole.ERROR, @@ -1451,6 +1605,372 @@ private static String summarize(String text, int max) { return flat.length() <= max ? flat : flat.substring(0, max) + "..."; } + private void submitAcpQuestion(String question) { + conversation.add(new ConversationEntry(AiRole.USER, question)); + log(LogLevel.QUESTION, "Question", question); + thinkingVerb = THINKING_VERBS.get(ThreadLocalRandom.current().nextInt(THINKING_VERBS.size())); + thinkingStartTime = System.currentTimeMillis(); + thinking.set(true); + // The panel keeps showing exactly what was typed (including an explicit /agent: escape), but the agent + // itself only ever sees its own command name: /agent:clear reaches it as /clear. + String wireQuestion = question.startsWith(AGENT_COMMAND_PREFIX) + ? "/" + question.substring(AGENT_COMMAND_PREFIX.length()) + : question; + agentThread = new Thread(() -> { + try { + runAcpTurn(wireQuestion); + } catch (IOException | RuntimeException e) { + reportAcpTurnFailure(e); + } finally { + if (agentThread == Thread.currentThread()) { + thinking.set(false); + agentThread = null; + } + } + }, "tui-ai-agent"); + agentThread.setDaemon(true); + agentThread.start(); + } + + /** + * Reports a failed ACP turn. The agent process and its session are only thrown away when the failure means they are + * gone anyway, so a JSON-RPC error from a healthy agent does not cost the user the conversation context. An + * interrupted turn is already reported as "(cancelled)" by {@link #interruptBusyOperation()}. + */ + private void reportAcpTurnFailure(Exception e) { + AcpAgentClient.AcpException acp = e instanceof AcpAgentClient.AcpException a ? a : null; + if (acp != null && acp.code() == AcpAgentClient.INTERRUPTED) { + return; + } + String message = e.getMessage() != null ? e.getMessage() : e.toString(); + conversation.add(new ConversationEntry(AiRole.ERROR, message)); + log(LogLevel.ERROR, "ACP error", message); + AcpAgentClient agent = acpClient; + boolean dead = agent == null || !agent.isAlive() + || (acp != null && (acp.code() == AcpAgentClient.CONNECTION || acp.code() == AcpAgentClient.TIMEOUT)); + if (dead) { + closeAcpClient(); + } + } + + private void runAcpTurn(String question) throws IOException { + AcpAgentClient agent = ensureAcpSession(); + boolean agentCommand = question.startsWith("/"); + String text = acpPreambleSent || agentCommand ? question : buildSystemPrompt() + "\n\n" + question; + AcpTurnListener listener = new AcpTurnListener(); + String stopReason = agent.prompt(acpSessionId, text, listener); + if (!agentCommand) { + acpPreambleSent = true; + } + listener.finish(stopReason); + scrollOffset = 0; + } + + /** + * The ACP agent's advertised commands as display-only descriptors (null executor: they are forwarded, never run + * locally). Displayed and completed under the {@code agent:} name so they never collide with a panel command of the + * same name; the bare name is registered as an alias so typing it still suggests the prefixed form. + */ + private List agentCommandDescriptors() { + AcpAgentClient agent = acpClient; + if (acpPreset == null || agent == null) { + return List.of(); + } + List out = new ArrayList<>(); + for (AcpAgentClient.AgentCommand command : agent.availableCommands()) { + out.add(new AiSlashCommandRegistry.Descriptor( + "agent:" + command.name(), List.of(command.name()), command.description(), command.hint(), null)); + } + return out; + } + + /** + * Formats the {@code /agent:} listing shown when the prefix is typed alone, e.g. {@code /agent:review focus area}. + */ + private String agentCommandListing() { + List commands = agentCommandDescriptors(); + if (commands.isEmpty()) { + return "The agent has not advertised any commands yet."; + } + List sorted = commands.stream() + .sorted(Comparator.comparing(AiSlashCommandRegistry.Descriptor::name)) + .toList(); + int width = AiSlashCommandRegistry.commandColumnWidth(sorted); + StringBuilder sb = new StringBuilder("Agent commands (").append(sorted.size()).append(")\n\n```\n"); + for (AiSlashCommandRegistry.Descriptor descriptor : sorted) { + sb.append(AiSlashCommandRegistry.formatAlignedLine(descriptor, width)).append('\n'); + } + return sb.append("```").toString(); + } + + /** + * Spawns the agent and negotiates the protocol on first use, then opens a session when none is active (first + * prompt, or after /clear). Runs on the agent thread; every failure is reported by the caller. + */ + private AcpAgentClient ensureAcpSession() throws IOException { + AiProviderSelector.AcpPreset preset = acpPreset; + AcpAgentClient agent = acpClient; + if (agent == null || !agent.isAlive()) { + closeAcpClient(); + String mcpUrl; + try { + mcpUrl = mcpUrlSupplier != null ? mcpUrlSupplier.call() : null; + } catch (Exception e) { + throw new IllegalStateException("Could not start the TUI MCP server: " + e.getMessage(), e); + } + if (mcpUrl == null) { + throw new IllegalStateException("The TUI MCP server is not available in this session."); + } + Path cwd = acpWorkingDir(); + conversation.add(new ConversationEntry(AiRole.SYSTEM, "Starting " + preset.label() + "…")); + agent = acpClientFactory.create(preset, cwd); + agent.setPermissionHandler(new AcpPanelPermissionHandler()); + AcpAgentClient.AgentInfo info; + try { + info = agent.initialize(ACP_INIT_TIMEOUT); + if (info.protocolVersion() != AcpAgentClient.PROTOCOL_VERSION) { + String mismatch = preset.label() + " negotiated ACP protocol version " + info.protocolVersion() + + "; the TUI supports version " + AcpAgentClient.PROTOCOL_VERSION + "."; + throw new IllegalStateException(mismatch); + } + if (!info.httpMcp()) { + throw new IllegalStateException( + preset.label() + " does not support HTTP MCP servers, so it cannot reach the TUI tools."); + } + } catch (RuntimeException e) { + agent.close(); + throw e; + } + acpAgentInfo = info; + // written before the volatile acpClient write below, which publishes them to the other threads + acpMcpUrl = mcpUrl; + acpCwd = cwd; + acpClient = agent; + acpSessionId = null; + log(LogLevel.RESULT, "ACP agent started", info.label() + " cwd=" + cwd); + } + if (acpSessionId == null) { + acpSessionId = openAcpSession(agent, acpAgentInfo, acpMcpUrl, acpCwd); + acpPreambleSent = false; + acpContextUsed = 0; + acpContextSize = 0; + log(LogLevel.RESULT, "ACP session", acpSessionId); + } + return agent; + } + + private String openAcpSession(AcpAgentClient agent, AcpAgentClient.AgentInfo info, String mcpUrl, Path cwd) { + try { + return agent.newSession(cwd, mcpUrl, ACP_SESSION_TIMEOUT); + } catch (AcpAgentClient.AcpException e) { + if (e.code() != AcpAgentClient.AUTH_REQUIRED) { + throw e; + } + AcpAgentClient.AuthMethod method = info.authMethods().stream() + .filter(m -> m.id() != null && (m.type() == null || "agent".equals(m.type()))) + .findFirst() + .orElse(null); + if (method == null) { + throw new IllegalStateException("Authentication required. " + acpPreset.loginHint()); + } + conversation.add(new ConversationEntry( + AiRole.SYSTEM, "Authenticating with " + info.label() + " (" + method.name() + ")…")); + try { + agent.authenticate(method.id(), ACP_AUTH_TIMEOUT); + return agent.newSession(cwd, mcpUrl, ACP_SESSION_TIMEOUT); + } catch (AcpAgentClient.AcpException retry) { + throw new IllegalStateException( + "Authentication failed: " + retry.getMessage() + ". " + acpPreset.loginHint()); + } + } + } + + private AcpHeaderStrip.Model acpHeaderModel() { + AiProviderSelector.AcpPreset preset = acpPreset; + AcpAgentClient agent = acpClient; + int commands = agent != null ? agent.availableCommands().size() : 0; + return new AcpHeaderStrip.Model( + preset.label(), preset.glyph(), preset.color(), acpLabel(), acpSessionId, acpCwd, commands); + } + + private Path acpWorkingDir() { + IntegrationInfo info = ctx != null ? ctx.findSelectedIntegration() : null; + if (info != null && info.configProperties != null) { + Path dir = FilesBrowser.resolveSourceDirectory(info); + if (dir != null && Files.isDirectory(dir)) { + return dir.toAbsolutePath(); + } + } + return Path.of("").toAbsolutePath(); + } + + private int replaceOrAppend(int index, ConversationEntry entry) { + if (index >= 0 && index < conversation.size()) { + conversation.set(index, entry); + return index; + } + conversation.add(entry); + return conversation.size() - 1; + } + + /** + * Turns agent updates into conversation entries. The callbacks run on the ACP reader thread while + * {@link #finish(String)} runs on the agent thread, and cancelling a turn returns from + * {@link AcpAgentClient#prompt} without waiting for the reader, so every method is synchronized on the listener. + */ + private final class AcpTurnListener implements AcpAgentClient.Listener { + private final StringBuilder text = new StringBuilder(); + private int liveIndex = -1; + private final Map toolLines = new HashMap<>(); + private final Map toolTitles = new HashMap<>(); + private volatile long usedTokens; + private volatile long contextSize; + + @Override + public synchronized void onTextChunk(String chunk) { + text.append(chunk); + liveIndex = replaceOrAppend(liveIndex, new ConversationEntry(AiRole.ASSISTANT, text.toString())); + scrollOffset = 0; + } + + @Override + public synchronized void onToolCall(String toolCallId, String title, String kind, JsonObject rawInput) { + conversation.add(new ConversationEntry(AiRole.SYSTEM, TuiIcons.GEAR + " " + title)); + if (toolCallId != null) { + toolLines.put(toolCallId, conversation.size() - 1); + toolTitles.put(toolCallId, title); + } + // text after a tool call starts a new assistant entry below the tool line + liveIndex = -1; + text.setLength(0); + log(LogLevel.TOOL, title, rawInput != null ? rawInput.toJson() : ""); + } + + @Override + public synchronized void onToolCallUpdate(String toolCallId, String status, String contentText) { + Integer index = toolCallId != null ? toolLines.get(toolCallId) : null; + if (index == null) { + return; + } + String title = toolTitles.getOrDefault(toolCallId, "tool"); + if ("completed".equals(status)) { + replaceOrAppend(index, new ConversationEntry(AiRole.SYSTEM, TuiIcons.CHECK + " " + title)); + log(LogLevel.RESULT, title, contentText != null ? contentText : "completed"); + } else if ("failed".equals(status)) { + replaceOrAppend(index, new ConversationEntry(AiRole.SYSTEM, TuiIcons.CROSS + " " + title)); + log(LogLevel.ERROR, title, contentText != null ? contentText : "failed"); + } + } + + @Override + public synchronized void onUsage(long used, long size) { + usedTokens = used; + contextSize = size; + } + + synchronized void finish(String stopReason) { + long elapsed = System.currentTimeMillis() - thinkingStartTime; + // the agent reports what its context holds now, not what the turn spent: the turn is the growth, and a + // context that shrank because the agent compacted spends nothing + long previous = acpContextUsed; + acpContextUsed = usedTokens; + acpContextSize = contextSize; + int tokens = (int) Math.min(Integer.MAX_VALUE, Math.max(0, usedTokens - previous)); + if (liveIndex >= 0) { + // the agent runs its own tools, so ACP reports no ai/tool split to fill in + replaceOrAppend(liveIndex, + new ConversationEntry(AiRole.ASSISTANT, text.toString(), elapsed, 0, 0, 0, tokens)); + } + if (usedTokens > 0) { + AiProviderSelector.AcpPreset preset = acpPreset; + sessionTotalTokens += tokens; + usageHistory.add(new AiUsageEntry( + acpLabel(), preset != null ? preset.id() : "acp", 0, 0, tokens, elapsed, + stopReason, Instant.now())); + } + switch (stopReason) { + case "end_turn", "cancelled" -> { + // cancelled is reported by interruptBusyOperation() + } + case "refusal" -> conversation.add(new ConversationEntry(AiRole.ERROR, "The agent refused to continue.")); + default -> conversation.add(new ConversationEntry(AiRole.SYSTEM, "(stopped: " + stopReason + ")")); + } + log(LogLevel.RESPONSE, "Response (" + formatSeconds(elapsed) + ", " + stopReason + ")", text.toString()); + } + } + + /** + * Policy A from the design: calls to the TUI's read-only tools are approved silently (allow-always preferred), + * everything else, including a TUI tool that changes something, is put in front of the user. Runs on the ACP + * request thread and blocks until the user answers or the turn is cancelled. + */ + private final class AcpPanelPermissionHandler implements AcpAgentClient.PermissionHandler { + @Override + public String decide(JsonObject toolCall, List options) { + String name = String.valueOf(toolCall.getStringOrDefault("name", "")); + String title = String.valueOf(toolCall.getStringOrDefault("title", "")); + String kind = String.valueOf(toolCall.getStringOrDefault("kind", "")); + if (isTuiTool(name, title, kind)) { + String optionId = firstOptionOfKind(options, "allow_always"); + if (optionId == null) { + optionId = firstOptionOfKind(options, "allow_once"); + } + if (optionId == null && !options.isEmpty()) { + optionId = options.get(0).getString("optionId"); + } + log(LogLevel.TOOL, "Auto-approved read-only TUI tool", title); + return optionId; + } + CompletableFuture decision = new CompletableFuture<>(); + pendingPermission = decision; + permissionPopup.open(toolCall, options); + log(LogLevel.TOOL, "Permission requested", title); + try { + return decision.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return null; + } catch (ExecutionException e) { + return null; + } finally { + pendingPermission = null; + permissionPopup.close(); + } + } + } + + /** + * Only a call to one of the TUI's {@link TuiToolRegistry#READ_ONLY_TOOLS} counts as auto-approvable: the name the + * Claude adapter sends ({@code mcp__camel-tui__tui_get_state}) or a title of the form + * {@code tui_get_state (camel-tui MCP Server)}. A TUI tool that changes anything is not in that set and goes to the + * user. Kinds that touch files or run commands never qualify, whatever the title says: a path containing + * "camel-tui" is not a tool identity. + */ + private boolean isTuiTool(String name, String title, String kind) { + if (FILE_OR_SHELL_KINDS.contains(kind)) { + return false; + } + String tool = null; + if (name.startsWith(TUI_TOOL_PREFIX)) { + tool = name.substring(TUI_TOOL_PREFIX.length()); + } else { + int paren = title.indexOf("(camel-tui"); + if (paren > 0) { + tool = title.substring(0, paren).strip(); + } + } + return tool != null && TuiToolRegistry.READ_ONLY_TOOLS.contains(tool); + } + + private static String firstOptionOfKind(List options, String kind) { + for (JsonObject option : options) { + if (kind.equals(option.getString("kind"))) { + return option.getString("optionId"); + } + } + return null; + } + private void recordUsage(LlmClient.ChatResponse response, long latencyMs) { if (client == null || response.usage().totalTokens() == 0) { return; @@ -1478,6 +1998,10 @@ void render(Frame frame, Rect area) { titleLine = Line.from( Span.styled(" AI ", Style.EMPTY.bold()), Span.styled("(" + formatSeconds(titleElapsed) + tokenSuffix + ") ", Style.EMPTY.dim())); + } else if (acpPreset != null) { + titleLine = Line.from( + Span.styled(" AI ", Style.EMPTY.bold()), + Span.styled("· " + acpLabel() + describeAcpContextSuffix() + " ", Style.EMPTY.dim())); } else if (sessionTotalTokens > 0) { titleLine = Line.from( Span.styled(" AI ", Style.EMPTY.bold()), @@ -1506,10 +2030,24 @@ void render(Frame frame, Rect area) { if (copyPopup.isVisible()) { copyPopup.render(frame, inner); } + if (permissionPopup.isVisible()) { + permissionPopup.render(frame, inner); + } return; } - // Split inner area: conversation (fill) + optional slash hints + separator (1 row) + input (1 row per line) + Rect body = inner; + if (acpPreset != null && acpSessionId != null && inner.height() >= 8) { + List top = Layout.vertical() + .constraints(Constraint.length(AcpHeaderStrip.ROWS), Constraint.length(1), Constraint.fill()) + .split(inner); + acpHeader.render(frame, top.get(0), acpHeaderModel()); + frame.renderWidget(Paragraph.from(Line.from(Span.styled("─".repeat(top.get(1).width()), Style.EMPTY.dim()))), + top.get(1)); + body = top.get(2); + } + + // Split the body area: conversation (fill) + optional slash hints + separator (1 row) + input (1 row per line) List slashHints = slashCommandHints(); int hintRows = slashHints.isEmpty() ? 0 : slashHints.size(); int inputRows = historySearchActive ? 1 : inputRows(); @@ -1517,12 +2055,12 @@ void render(Frame frame, Rect area) { if (hintRows == 0) { parts = Layout.vertical() .constraints(Constraint.fill(), Constraint.length(1), Constraint.length(inputRows)) - .split(inner); + .split(body); } else { parts = Layout.vertical() .constraints(Constraint.fill(), Constraint.length(hintRows), Constraint.length(1), Constraint.length(inputRows)) - .split(inner); + .split(body); } Rect conversationArea = parts.get(0); Rect separatorArea = parts.get(hintRows == 0 ? 1 : 2); @@ -1543,13 +2081,16 @@ void render(Frame frame, Rect area) { if (copyPopup.isVisible()) { copyPopup.render(frame, inner); } + if (permissionPopup.isVisible()) { + permissionPopup.render(frame, inner); + } } private List slashCommandHints() { if (thinking.get() || statsView || providerSwitchPopup.isVisible()) { return List.of(); } - return slashCommands.completionsFor(inputBuffer.toString()); + return slashCommands.completionsFor(inputBuffer.toString(), agentCommandDescriptors()); } private void renderSlashCommandHints(Frame frame, Rect area, List hints) { @@ -1800,6 +2341,10 @@ private void renderSearchInput(Frame frame, Rect area) { } void renderFooter(List spans) { + if (permissionPopup.isVisible()) { + permissionPopup.renderFooter(spans); + return; + } if (providerSwitchPopup.isVisible()) { providerSwitchPopup.renderFooter(spans); return; @@ -2373,6 +2918,10 @@ private boolean useCoreTools() { } private String describeToolMode() { + if (acpPreset != null) { + return acpLabel() + " reaches the camel-tui tools through the MCP server directly; " + + "the tool set only applies to LLM providers"; + } if (toolRegistry == null) { return "no tools available"; } @@ -2550,6 +3099,9 @@ private long toolSchemaChars() { } String describeContext() { + if (acpPreset != null) { + return describeAcpContext(); + } StringBuilder sb = new StringBuilder(); if (client == null) { sb.append("Provider: none (").append(initError != null ? initError : "no LLM client").append(")\n"); @@ -2579,7 +3131,71 @@ String describeContext() { return sb.toString(); } + /** The title's context figure, empty until the agent reports one. */ + private String describeAcpContextSuffix() { + long used = acpContextUsed; + if (used <= 0) { + return ""; + } + long size = acpContextSize; + return " (context: " + formatAcpTokens(used) + (size > 0 ? "/" + formatAcpTokens(size) : "") + " tokens)"; + } + + private static String formatAcpTokens(long tokens) { + return LlmClient.formatTokens((int) Math.min(Integer.MAX_VALUE, tokens)); + } + + /** + * The {@code /context} answer while an ACP agent is selected. The agent owns the conversation history and runs its + * own tools, so the interesting figures are its session, the MCP server it was given and the preamble the panel + * prepends to the first prompt. + */ + private String describeAcpContext() { + AiProviderSelector.AcpPreset preset = acpPreset; + AcpAgentClient agent = acpClient; + StringBuilder sb = new StringBuilder(); + sb.append("Agent: ").append(acpLabel()) + .append(" (preset ").append(preset != null ? preset.id() : "acp").append(")\n"); + if (acpSessionId != null) { + sb.append("Session: ").append(acpSessionId).append(" in ").append(acpCwd).append('\n'); + sb.append("MCP: ").append(acpMcpUrl).append(" (read-only camel-tui tools are approved automatically)\n"); + } else { + sb.append("Session: not started yet; the next prompt opens one and starts the MCP server on demand\n"); + } + sb.append("Agent commands: ").append(agent != null ? agent.availableCommands().size() : 0) + .append(" (/agent: lists them)\n"); + sb.append("Preamble: ~").append(LlmClient.formatTokens(estimateTokens(buildSystemPrompt().length()))) + .append(" tokens, sent once per session ahead of the first prompt (/prompt shows it); ") + .append(acpPreambleSent ? "sent" : "not sent yet").append('\n'); + if (acpContextUsed > 0) { + sb.append("Context: ").append(formatAcpTokens(acpContextUsed)); + if (acpContextSize > 0) { + sb.append(" of ").append(formatAcpTokens(acpContextSize)); + } + sb.append(" tokens in the agent's window\n"); + } else { + sb.append("Context: not reported yet\n"); + } + sb.append("History and tools are managed by the agent: /compact and /tools do not apply here"); + return sb.toString(); + } + + /** + * Explains that a panel command has no meaning while an agent is selected, pointing at the agent's own command of + * the same name when it advertises one. + */ + private String acpNotApplicable(String command, String what) { + AcpAgentClient agent = acpClient; + boolean offered = agent != null + && agent.availableCommands().stream().anyMatch(c -> command.equals(c.name())); + return "/" + command + " does not apply here: " + acpLabel() + " " + what + "." + + (offered ? " Use /agent:" + command + "." : ""); + } + String compactHistoryNow() { + if (acpPreset != null) { + return acpNotApplicable("compact", "manages its own conversation history"); + } if (messages == null || messages.isEmpty()) { return "History is empty, nothing to compact"; } @@ -2593,10 +3209,11 @@ String compactHistoryNow() { /** * Resends the last question. Any messages from the previous attempt (the question and whatever followed it) are - * removed from the model history first so the retry starts from a clean turn. + * removed from the model history first so the retry starts from a clean turn; an ACP agent keeps its own history, + * so the question is simply sent again. */ boolean retryLastQuestion() { - if (client == null || thinking.get()) { + if ((client == null && acpPreset == null) || thinking.get()) { return false; } String question = null; @@ -2609,7 +3226,7 @@ boolean retryLastQuestion() { if (question == null || question.isBlank()) { return false; } - if (messages != null) { + if (acpPreset == null && messages != null) { for (int i = messages.size() - 1; i >= 0; i--) { LlmClient.Message m = messages.get(i); if ("user".equals(m.role()) && m.toolCalls() == null && m.toolResults() == null) { @@ -2737,6 +3354,10 @@ void executeSlashCommandForTesting(String input) { } void clearConversation() { + if (acpPreset != null && thinking.get()) { + // an abandoned ACP turn would keep writing into the list we are about to clear + interruptBusyOperation(); + } conversation.clear(); activityLog.clear(); inputBuffer.setLength(0); @@ -2751,6 +3372,11 @@ void clearConversation() { if (messages != null) { messages.clear(); } + // the next prompt opens a fresh agent session (context reset), keeping the process + acpSessionId = null; + acpPreambleSent = false; + acpContextUsed = 0; + acpContextSize = 0; } void setPromptHistoryForTesting(TuiPromptHistory history) { @@ -2768,6 +3394,43 @@ void setClientForTesting(LlmClient client) { this.testingClientInjected = true; } + void selectProviderForTesting(String providerId) { + applyProviderChoice(new AiProviderSwitchPopup.ProviderChoice(providerId, "", "", false)); + } + + boolean isAcpProviderForTesting() { + return acpPreset != null; + } + + void setAcpClientFactoryForTesting(AcpClientFactory factory) { + this.acpClientFactory = factory; + } + + void setMcpUrlSupplierForTestingOrRuntime(Callable supplier) { + this.mcpUrlSupplier = supplier; + } + + String acpSessionIdForTesting() { + return acpSessionId; + } + + boolean isPermissionPopupVisibleForTesting() { + return permissionPopup.isVisible(); + } + + private AcpAgentClient spawnAcpAgent(AiProviderSelector.AcpPreset preset, Path cwd) throws IOException { + // ProcessBuilder only tries .exe on Windows, so the resolved file (npx.cmd and friends) has to be launched + String resolved = AiProviderSelector.resolveExecutable(preset.executable()); + if (resolved == null) { + throw new IOException(preset.installHint()); + } + List command = new ArrayList<>(preset.command()); + if (!command.isEmpty() && command.get(0).equals(preset.executable())) { + command.set(0, resolved); + } + return AcpAgentClient.spawn(command, cwd, line -> log(LogLevel.ERROR, "ACP", line)); + } + void setSlashCommandContextForTesting(AiSlashCommandContext context) { this.slashCommandContext = context; } @@ -2784,6 +3447,10 @@ int sessionTotalTokensForTesting() { return sessionTotalTokens; } + long[] acpContextForTesting() { + return new long[] { acpContextUsed, acpContextSize }; + } + int messageCountForTesting() { return messages == null ? 0 : messages.size(); } @@ -2907,16 +3574,26 @@ public void clearHistory() { @Override public String currentModel() { + if (acpPreset != null) { + return acpLabel(); + } return client != null && client.model() != null ? client.model() : "unknown"; } @Override public List availableModels() { + if (acpPreset != null) { + return List.of(); + } return client != null ? client.listModels() : List.of(); } @Override public boolean switchModel(String model) { + if (acpPreset != null) { + throw new IllegalStateException( + "The model is configured in the agent (" + acpLabel() + "), not in the TUI."); + } if (client == null) { return false; } @@ -2937,6 +3614,11 @@ public String describeToolMode() { @Override public boolean switchToolMode(String mode) { + if (acpPreset != null) { + throw new IllegalStateException( + "The tool set only applies to LLM providers; " + acpLabel() + + " reaches the camel-tui tools through the MCP server directly."); + } String normalized = normalizeToolMode(mode); if (normalized == null) { return false; @@ -3009,6 +3691,9 @@ public void exportConversation() { @Override public String systemPrompt() { + if (acpPreset != null) { + return "Sent to " + acpLabel() + " ahead of the first prompt of each session:\n\n" + buildSystemPrompt(); + } return buildSystemPrompt(); } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelector.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelector.java index d691a583b3b70..e65678a61ea8d 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelector.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelector.java @@ -16,11 +16,15 @@ */ package org.apache.camel.dsl.jbang.core.commands.tui; +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; +import dev.tamboui.style.Color; import org.apache.camel.dsl.jbang.core.commands.LlmClient; /** @@ -32,6 +36,138 @@ */ final class AiProviderSelector { + static final String ACP_PREFIX = "acp:"; + static final String ACP_CUSTOM = "acp:custom"; + private static final String NPX_HINT = "npx not found: install Node.js 22 or newer (https://nodejs.org) and try again."; + + /** + * One external ACP agent the panel knows how to launch. {@code executable} is the first token of the command, + * checked on the PATH before spawning so a missing tool yields {@code installHint} instead of an obscure error. + * {@code glyph} and {@code color} identify the agent in the panel's header strip. + */ + record AcpPreset(String id, String label, List command, String executable, String loginHint, + String installHint, String glyph, Color color) { + } + + private static final List ACP_PRESETS = List.of( + new AcpPreset( + "acp:claude", "Claude Code (ACP)", + List.of("npx", "-y", "@agentclientprotocol/claude-agent-acp"), "npx", + "Log in with the claude CLI or set ANTHROPIC_API_KEY, then ask again.", NPX_HINT, + "✱", Color.rgb(0xD9, 0x77, 0x57)), + new AcpPreset( + "acp:codex", "Codex (ACP)", + List.of("npx", "-y", "@agentclientprotocol/codex-acp"), "npx", + "Run `codex login` or set OPENAI_API_KEY, then ask again.", NPX_HINT, + "⬢", Color.rgb(0x10, 0xA3, 0x7F)), + new AcpPreset( + "acp:bob", "IBM Bob (ACP)", + List.of("bob", "acp"), "bob", + "Set BOBSHELL_API_KEY or run `bob` once to sign in, then ask again.", + "bob not found: install Bob Shell (https://bob.ibm.com/docs/shell) and try again.", + "◆", Color.rgb(0x0F, 0x62, 0xFE)), + new AcpPreset( + "acp:qwen", "Qwen Code (ACP)", + List.of("qwen", "--acp"), "qwen", + "Set OPENAI_API_KEY and OPENAI_BASE_URL for Qwen Code, then ask again.", + "qwen not found: npm install -g @qwen-code/qwen-code and try again.", + "✦", Color.rgb(0x61, 0x5C, 0xED)), + new AcpPreset( + "acp:opencode", "OpenCode (ACP)", + List.of("opencode", "acp"), "opencode", + "Run `opencode auth login`, then ask again.", + "opencode not found: install it from https://opencode.ai and try again.", + "▣", Color.rgb(0x9F, 0xD3, 0x5B)), + new AcpPreset( + "acp:dsh", "DeepSeek Harness (ACP, preview)", + List.of("npx", "-y", "@deepseek-ai/dsh", "--profile", "acp"), "npx", + "Configure the model key in DeepSeek Harness, then ask again.", NPX_HINT, + "◉", Color.rgb(0x4D, 0x6B, 0xFE))); + + static List acpPresets() { + return ACP_PRESETS; + } + + static boolean isAcp(String provider) { + return provider != null && provider.startsWith(ACP_PREFIX); + } + + static String acpLabel(String provider) { + for (AcpPreset preset : ACP_PRESETS) { + if (preset.id().equals(provider)) { + return preset.label(); + } + } + return ACP_CUSTOM.equals(provider) ? "Custom (ACP)" : provider; + } + + /** + * Resolves the preset for an {@code acp:*} provider id. The custom provider builds its command from + * {@code camel.tui.ai.acp.command}, split on whitespace (no quoting support). + * + * @throws IllegalArgumentException for an unknown id, or the custom id without a configured command + */ + AcpPreset acpPreset(String provider, TuiSettings settings) { + for (AcpPreset preset : ACP_PRESETS) { + if (preset.id().equals(provider)) { + return preset; + } + } + if (ACP_CUSTOM.equals(provider)) { + String raw = settings.getAiAcpCommand(); + if (raw == null || raw.isBlank()) { + throw new IllegalArgumentException( + "No custom ACP command configured. Set camel.tui.ai.acp.command in F2 -> Settings."); + } + List command = List.of(raw.trim().split("\\s+")); + return new AcpPreset( + ACP_CUSTOM, "Custom (ACP)", command, command.get(0), + "Check the agent's own login instructions, then ask again.", + command.get(0) + " not found: check camel.tui.ai.acp.command.", + "●", Theme.ACCENT); + } + throw new IllegalArgumentException("Unknown ACP provider '" + provider + "'."); + } + + /** + * Where {@code executable} is found on {@code path}, with the {@code .cmd}/{@code .exe} suffix Windows needs; null + * when absent. An absolute {@code executable} resolves to itself when it is executable. + */ + static String resolveExecutable(String executable, String path) { + Path direct = Path.of(executable); + if (direct.isAbsolute()) { + return firstExecutable(direct); + } + if (path == null) { + return null; + } + for (String dir : path.split(File.pathSeparator)) { + if (dir.isBlank()) { + continue; + } + String found = firstExecutable(Path.of(dir).resolve(executable)); + if (found != null) { + return found; + } + } + return null; + } + + /** The candidate itself or its {@code .cmd}/{@code .exe} sibling, whichever is executable; null when none is. */ + private static String firstExecutable(Path candidate) { + for (Path variant : List.of(candidate, Path.of(candidate + ".cmd"), Path.of(candidate + ".exe"))) { + if (Files.isExecutable(variant)) { + return variant.toString(); + } + } + return null; + } + + /** Same, on the process PATH: what {@code ProcessBuilder} must be given so a Windows shim is actually launched. */ + static String resolveExecutable(String executable) { + return resolveExecutable(executable, System.getenv("PATH")); + } + /** * Builds the ordered provider choices for the switch popup: the persisted default first, followed by every other * known provider (regardless of whether an API key is currently detected for it, so it stays available for manual @@ -46,20 +182,19 @@ List buildChoices() { settings.getAiModel() != null ? settings.getAiModel() : "", settings.getAiUrl() != null ? settings.getAiUrl() : "", true)); - if (!"anthropic".equals(defaultProvider)) { - choices.add(new AiProviderSwitchPopup.ProviderChoice("anthropic", "", "", false)); - } - if (!"openai".equals(defaultProvider)) { - choices.add(new AiProviderSwitchPopup.ProviderChoice("openai", "", "", false)); + for (String provider : List.of("anthropic", "openai", "gemini", "ollama", "watsonx")) { + if (!provider.equals(defaultProvider)) { + choices.add(new AiProviderSwitchPopup.ProviderChoice(provider, "", "", false)); + } } - if (!"gemini".equals(defaultProvider)) { - choices.add(new AiProviderSwitchPopup.ProviderChoice("gemini", "", "", false)); + for (AcpPreset preset : ACP_PRESETS) { + if (!preset.id().equals(defaultProvider)) { + choices.add(new AiProviderSwitchPopup.ProviderChoice(preset.id(), "", "", false)); + } } - if (!"ollama".equals(defaultProvider)) { - choices.add(new AiProviderSwitchPopup.ProviderChoice("ollama", "", "", false)); - } - if (!"watsonx".equals(defaultProvider)) { - choices.add(new AiProviderSwitchPopup.ProviderChoice("watsonx", "", "", false)); + String custom = settings.getAiAcpCommand(); + if (custom != null && !custom.isBlank() && !ACP_CUSTOM.equals(defaultProvider)) { + choices.add(new AiProviderSwitchPopup.ProviderChoice(ACP_CUSTOM, "", "", false)); } return choices; } @@ -71,6 +206,9 @@ List buildChoices() { * @throws IllegalArgumentException if {@code provider} is set and not a recognized {@link LlmClient.ApiType} */ void applyChoice(LlmClient target, String provider, String model, String url) { + if (isAcp(provider)) { + return; + } if (provider != null && !provider.isBlank() && !"auto".equals(provider)) { target.withApiType(parseApiType(provider)); } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSwitchPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSwitchPopup.java index ac19042dedcdb..6233d61ae015e 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSwitchPopup.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSwitchPopup.java @@ -47,6 +47,9 @@ record ProviderChoice(String provider, String model, String url, boolean persist } String label() { + if (AiProviderSelector.isAcp(provider)) { + return AiProviderSelector.acpLabel(provider) + (persistedDefault ? " default" : ""); + } String modelLabel = model == null || model.isBlank() ? "auto" : model; return provider + " " + modelLabel + (persistedDefault ? " default" : ""); } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistry.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistry.java index 833629380725f..118482badba82 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistry.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistry.java @@ -242,6 +242,38 @@ List completionsFor(String input) { return matchDescriptors(body); } + /** + * Like {@link #completionsFor(String)} but also offers {@code extra} descriptors, for example the slash commands an + * ACP agent advertised. Registry commands win: an extra descriptor whose name is a registry command or alias is + * dropped, and a complete extra command followed by a space hides the hints like a registry command does. + */ + List completionsFor(String input, List extra) { + List own = completionsFor(input); + if (extra.isEmpty() || input == null || !input.startsWith("/")) { + return own; + } + String body = input.substring(1); + int separator = firstWhitespace(body); + if (separator >= 0) { + String command = body.substring(0, separator); + if (!body.substring(separator).isBlank() || lookup(command).isPresent() + || extra.stream().anyMatch(d -> d.name().equalsIgnoreCase(command) + || d.aliases().stream().anyMatch(a -> a.equalsIgnoreCase(command)))) { + return own; + } + body = command; + } + String needle = body.strip().toLowerCase(Locale.ROOT); + List merged = new ArrayList<>(own); + for (Descriptor descriptor : extra) { + if (lookup(descriptor.name()).isEmpty() && matchesPrefix(descriptor, needle) + && merged.stream().noneMatch(m -> m.name().equals(descriptor.name()))) { + merged.add(descriptor); + } + } + return merged; + } + private List matchDescriptors(String prefix) { String needle = prefix.strip().toLowerCase(Locale.ROOT); return descriptors.stream().filter(descriptor -> matchesPrefix(descriptor, needle)).toList(); 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 244a6c46cd898..7003642b7ac82 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 @@ -141,7 +141,8 @@ public class CamelMonitor extends CamelCommand { @CommandLine.Option(names = { "--mcp-port" }, description = "MCP server port (default: ${DEFAULT-VALUE})", defaultValue = "8123") - int mcpPort = 8123; + // written by ensureMcpServer() on the AI panel's agent thread, read on the event and main threads + volatile int mcpPort = 8123; @CommandLine.Option(names = { "--web" }, description = "Enable browser-accessible terminal (WebSocket) server") @@ -170,7 +171,7 @@ public class CamelMonitor extends CamelCommand { private final WaveTextState notificationWaveState = new WaveTextState(); private String lastWaveNotification; private boolean mcpInjectedKey; - private TuiMcpServer mcpServer; + private volatile TuiMcpServer mcpServer; private Path mcpJsonFile; private TuiWebServer webServer; private McpFacade mcpFacade; @@ -967,9 +968,7 @@ private void startMcpServer() throws Exception { mcpServer = new TuiMcpServer(mcpPort, mcpFacade); try { mcpServer.start(); - mcpFacade.setMcpActivityLog(mcpServer::getActivityLog, mcpServer::getToolCallCount); - actionsPopup.setMcpEnabled(true, mcpPort, mcpServer::getConnectedClient, - mcpServer::getActivityLog, mcpServer::getToolCallCount); + wireMcpServer(mcpServer); mcpJsonFile = writeMcpJson(mcpPort); } catch (BindException e) { System.err.println("MCP server failed to start: port " + mcpPort + " is already in use."); @@ -979,6 +978,7 @@ private void startMcpServer() throws Exception { } } aiPanel.setMcpInfo(mcp, mcpPort); + aiPanel.setMcpUrlSupplierForTestingOrRuntime(this::ensureMcpServer); } /** @@ -2962,6 +2962,31 @@ private String selectedName() { // ---- MCP .mcp.json lifecycle ---- + /** + * Returns the Streamable HTTP URL of the embedded MCP server, starting it on an ephemeral localhost port if neither + * --mcp nor an earlier call did. Used by the AI panel to hand the TUI tools to an ACP agent. No .mcp.json is + * written for an on-demand server. Called from the AI panel's agent thread. + */ + synchronized String ensureMcpServer() throws IOException { + TuiMcpServer server = mcpServer; + if (server == null) { + server = new TuiMcpServer(mcp ? mcpPort : 0, mcpFacade); + server.start(); + mcpServer = server; + mcpPort = server.getPort(); + wireMcpServer(server); + } + return "http://127.0.0.1:" + server.getPort() + "/mcp"; + } + + /** Hands a started MCP server to the facade, the actions popup and the AI panel. */ + private void wireMcpServer(TuiMcpServer server) { + mcpFacade.setMcpActivityLog(server::getActivityLog, server::getToolCallCount); + actionsPopup.setMcpEnabled(true, mcpPort, server::getConnectedClient, + server::getActivityLog, server::getToolCallCount); + aiPanel.setMcpInfo(true, mcpPort); + } + private static Path writeMcpJson(int port) { Path path = Path.of(".mcp.json"); if (Files.exists(path)) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopup.java index aea9a61533c33..bdd8130afcd02 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopup.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopup.java @@ -63,7 +63,10 @@ class SettingsPopup { private static final int ROW_AI_URL = 15; private static final int ROW_AI_TOOLS = 16; private static final int ROW_AI_PROMPT_HISTORY = 17; - private static final int ROW_COUNT = 18; + private static final int ROW_AI_ACP_COMMAND = 18; + static final int ROW_COUNT = 19; + /** Separator lines drawn between the row groups, after rows 2, 6, 9 and 12. */ + static final int DIVIDERS = 4; private static final String[] LOG_PIN_OPTIONS = { "off", "25", "50", "75" }; private static final String[] RATE_PER_OPTIONS = { "seconds", "minutes" }; @@ -78,12 +81,20 @@ private static List buildAiProviderList() { for (LlmClient.ApiType apiType : LlmClient.ApiType.values()) { providers.add(apiType.name()); } + for (AiProviderSelector.AcpPreset preset : AiProviderSelector.acpPresets()) { + providers.add(preset.id()); + } + providers.add(AiProviderSelector.ACP_CUSTOM); providers.add("auto"); return providers; } private boolean visible; private int selectedRow; + /** First content line drawn, so the selected row stays inside a popup shorter than its 24 lines. */ + private int scrollTop; + private int clipTop; + private int clipBottom; private TuiSettings settings; private int themeIndex; @@ -104,6 +115,7 @@ private static List buildAiProviderList() { private TextInputState aiModelInput; private TextInputState aiUrlInput; private TextInputState aiPromptHistoryInput; + private TextInputState aiAcpCommandInput; private List tabNames = new ArrayList<>(); private List tabEntries; @@ -190,7 +202,9 @@ void open() { aiToolsIndex = Math.max(0, toolsIdx); aiPromptHistoryInput = new TextInputState( settings.getAiPromptHistory() != null ? settings.getAiPromptHistory() : ""); + aiAcpCommandInput = new TextInputState(settings.getAiAcpCommand() != null ? settings.getAiAcpCommand() : ""); selectedRow = ROW_THEME; + scrollTop = 0; visible = true; } @@ -342,6 +356,10 @@ boolean handleKeyEvent(KeyEvent ke) { handleTextInput(ke, aiPromptHistoryInput); return true; } + if (selectedRow == ROW_AI_ACP_COMMAND) { + handleTextInput(ke, aiAcpCommandInput); + return true; + } return true; } @@ -386,6 +404,7 @@ private void save() { String aiToolsValue = AI_TOOLS_OPTIONS[aiToolsIndex]; settings.setAiTools(AiPanel.TOOL_MODE_AUTO.equals(aiToolsValue) ? null : aiToolsValue); settings.setAiPromptHistory(stripControlChars(aiPromptHistoryInput.text().trim())); + settings.setAiAcpCommand(stripControlChars(aiAcpCommandInput.text().trim())); settings.save(); if (Theme.mode().equals(selectedThemeId)) { // Already active via live preview (or unchanged): just persist and clear the preview marker. @@ -399,9 +418,8 @@ private void save() { } void render(Frame frame, Rect area) { - int dividers = 4; int popupW = Math.min(70, area.width() - 4); - int popupH = 2 + ROW_COUNT + dividers; + int popupH = 2 + ROW_COUNT + DIVIDERS; int x = area.left() + Math.max(0, (area.width() - popupW) / 2); int y = area.top() + 2; Rect popup = new Rect(x, y, Math.min(popupW, area.width()), Math.min(popupH, area.height() - 2)); @@ -414,11 +432,23 @@ void render(Frame frame, Rect area) { .build(); frame.renderWidget(block, popup); + int visibleLines = popup.height() - 2; + int contentLines = ROW_COUNT + DIVIDERS; + int selectedLine = lineOf(selectedRow); + if (selectedLine < scrollTop) { + scrollTop = selectedLine; + } else if (selectedLine >= scrollTop + visibleLines) { + scrollTop = selectedLine - visibleLines + 1; + } + scrollTop = Math.max(0, Math.min(scrollTop, Math.max(0, contentLines - visibleLines))); + clipTop = popup.top() + 1; + clipBottom = popup.top() + popup.height() - 2; + int innerX = popup.left() + 2; int innerW = popup.width() - 4; int labelW = 24; int fieldW = innerW - labelW; - int rowY = popup.top() + 1; + int rowY = popup.top() + 1 - scrollTop; // --- Appearance --- renderLabel(frame, innerX, rowY, labelW, "Theme:", selectedRow == ROW_THEME); @@ -519,6 +549,11 @@ void render(Frame frame, Rect area) { renderLabel(frame, innerX, rowY, labelW, "AI History:", selectedRow == ROW_AI_PROMPT_HISTORY); renderTextInput(frame, innerX + labelW, rowY, fieldW, aiPromptHistoryInput, selectedRow == ROW_AI_PROMPT_HISTORY, "(100)"); + rowY++; + + renderLabel(frame, innerX, rowY, labelW, "ACP Command:", selectedRow == ROW_AI_ACP_COMMAND); + renderTextInput(frame, innerX + labelW, rowY, fieldW, aiAcpCommandInput, + selectedRow == ROW_AI_ACP_COMMAND, "(none)"); } void renderFooter(List spans) { @@ -597,15 +632,34 @@ private static String stripControlChars(String s) { return sb.toString(); } + /** The content line a row is drawn on, counting the dividers that follow rows 2, 6, 9 and 12. */ + static int lineOf(int row) { + return row + (row > 2 ? 1 : 0) + (row > 6 ? 1 : 0) + (row > 9 ? 1 : 0) + (row > 12 ? 1 : 0); + } + + /** True when a scrolled line falls outside the popup's inner area and must not be drawn. */ + private boolean clipped(int y) { + return y < clipTop || y > clipBottom; + } + private void renderDivider(Frame frame, int x, int y, int w) { + if (clipped(y)) { + return; + } frame.renderWidget(Paragraph.from(Line.from(Span.styled("─".repeat(w), Style.EMPTY.dim()))), new Rect(x, y, w, 1)); } private void renderLabel(Frame frame, int x, int y, int w, String label, boolean selected) { + if (clipped(y)) { + return; + } FormHelper.renderLabel(frame, x, y, w, label, selected); } private void renderValue(Frame frame, int x, int y, int w, String text, boolean selected) { + if (clipped(y)) { + return; + } Style style = selected ? Style.EMPTY.bold() : Style.EMPTY; frame.renderWidget(Paragraph.from(Line.from(Span.styled("[" + text + "]", style))), new Rect(x, y, w, 1)); } @@ -617,6 +671,9 @@ private void renderFolder(Frame frame, int x, int y, int w, boolean active) { private void renderTextInput( Frame frame, int x, int y, int w, TextInputState input, boolean active, String placeholder) { + if (clipped(y)) { + return; + } FormHelper.renderTextField(frame, new Rect(x, y, w, 1), input, active, placeholder); } @@ -670,6 +727,10 @@ private String aiToolsLabel() { }; } + List aiProviderOptionsForTesting() { + return AI_PROVIDERS; + } + String aiModelText() { return aiModelInput != null ? aiModelInput.text() : ""; } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java index d82da6b064041..c222298b545c3 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java @@ -44,6 +44,8 @@ final class TuiIcons { static final String HEALTH_WARN = "⚠"; static final String STOPPED = "✖"; static final String CROSS = "✗"; + static final String GEAR = "⚙"; + static final String CHECK = "✓"; // ---- Files & folders ---- static final String FOLDER = "📁"; diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java index a49bb1aff14f0..2c39d7c187684 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java @@ -91,7 +91,7 @@ void start() throws IOException { return t; })); server.start(); - log(LogLevel.INFO, "Server started on port " + port); + log(LogLevel.INFO, "Server started on port " + getPort()); } void stop() { @@ -103,6 +103,13 @@ void stop() { } } + /** + * The port the server is bound to. Differs from the constructor argument when {@code 0} (ephemeral) was requested. + */ + int getPort() { + return server != null ? server.getAddress().getPort() : port; + } + synchronized List getActivityLog() { return new ArrayList<>(activityLog); } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiSettings.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiSettings.java index c6ababc822175..a790019dfdff1 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiSettings.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiSettings.java @@ -40,6 +40,7 @@ final class TuiSettings { static final String PROP_AI_MODEL = "camel.tui.ai.model"; static final String PROP_AI_URL = "camel.tui.ai.url"; static final String PROP_AI_TOOLS = "camel.tui.ai.tools"; + static final String PROP_AI_ACP_COMMAND = "camel.tui.ai.acp.command"; static final String PROP_PROXY_HOST = "camel.tui.proxyHost"; static final String PROP_PROXY_PORT = "camel.tui.proxyPort"; static final String PROP_SHELL_HISTORY = "camel.tui.shell.history"; @@ -61,6 +62,7 @@ final class TuiSettings { private String aiModel; private String aiUrl; private String aiTools; + private String aiAcpCommand; private String shellHistory; private String aiPromptHistory; private String confirmActions; @@ -168,6 +170,14 @@ void setAiTools(String aiTools) { this.aiTools = aiTools; } + String getAiAcpCommand() { + return aiAcpCommand; + } + + void setAiAcpCommand(String aiAcpCommand) { + this.aiAcpCommand = aiAcpCommand; + } + String getShellHistory() { return shellHistory; } @@ -262,6 +272,7 @@ static TuiSettings load() { settings.aiModel = trimToNull(TuiUserConfig.read(PROP_AI_MODEL)); settings.aiUrl = trimToNull(TuiUserConfig.read(PROP_AI_URL)); settings.aiTools = trimToNull(TuiUserConfig.read(PROP_AI_TOOLS)); + settings.aiAcpCommand = trimToNull(TuiUserConfig.read(PROP_AI_ACP_COMMAND)); settings.shellHistory = trimToNull(TuiUserConfig.read(PROP_SHELL_HISTORY)); settings.aiPromptHistory = trimToNull(TuiUserConfig.read(PROP_AI_PROMPT_HISTORY)); settings.confirmActions = trimToNull(TuiUserConfig.read(PROP_CONFIRM_ACTIONS)); @@ -293,6 +304,7 @@ void save() { TuiUserConfig.write(PROP_AI_MODEL, aiModel); TuiUserConfig.write(PROP_AI_URL, aiUrl); TuiUserConfig.write(PROP_AI_TOOLS, aiTools); + TuiUserConfig.write(PROP_AI_ACP_COMMAND, aiAcpCommand); TuiUserConfig.write(PROP_SHELL_HISTORY, shellHistory); TuiUserConfig.write(PROP_AI_PROMPT_HISTORY, aiPromptHistory); TuiUserConfig.write(PROP_CONFIRM_ACTIONS, confirmActions); 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 490fdfc383e2f..cb8b509d7bca4 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 @@ -98,6 +98,18 @@ void setLaunchManager(LaunchManager launchManager) { "tui_get_readme", "tui_navigate", "tui_set_log_level", "tui_filter", "tui_get_status", "tui_infra"); + /** + * Tools that only return information and never change the TUI, the integration or its data. The ACP permission + * handler approves calls to these without asking; anything else, including tui_control, tui_send_message and + * tui_execute_sql, is put in front of the user. + */ + static final Set READ_ONLY_TOOLS = Set.of( + "tui_catalog_doc", "tui_get_ai_log", "tui_get_diagram", "tui_get_errors", "tui_get_events", + "tui_get_files", "tui_get_history", "tui_get_log", "tui_get_mcp_log", "tui_get_options", + "tui_get_processor_detail", "tui_get_readme", "tui_get_screen", "tui_get_spans", "tui_get_state", + "tui_get_status", "tui_get_table", "tui_get_themes", "tui_get_topology", "tui_list_examples", + "tui_locate", "tui_validate_source", "tui_wait_for_idle"); + /** * Returns all tool definitions. The result is cached since it is immutable. */ diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpAgentClientTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpAgentClientTest.java new file mode 100644 index 0000000000000..bc77f198f9258 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpAgentClientTest.java @@ -0,0 +1,553 @@ +/* + * 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.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +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.Timeout; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AcpAgentClientTest { + + private static final Duration T = Duration.ofSeconds(5); + + private FakeAcpAgent agent; + private AcpAgentClient client; + private final List diagnostics = new CopyOnWriteArrayList<>(); + + @BeforeEach + void setUp() throws Exception { + agent = new FakeAcpAgent(); + client = new AcpAgentClient(agent.clientInput(), agent.clientOutput(), diagnostics::add); + client.start(); + } + + @AfterEach + void tearDown() { + client.close(); + agent.close(); + } + + @Test + void initializeSendsVersion1WithNoFsOrTerminalCapability() { + AcpAgentClient.AgentInfo info = client.initialize(T); + assertEquals(1, info.protocolVersion()); + assertTrue(info.httpMcp()); + assertEquals("fake-agent", info.name()); + assertEquals("1.2.3", info.version()); + JsonObject sent = agent.awaitReceived("initialize", T); + JsonObject params = sent.getJsonObject("params"); + assertEquals(1, params.getInteger("protocolVersion")); + JsonObject fs = params.getJsonObject("clientCapabilities").getJsonObject("fs"); + assertFalse(fs.getBoolean("readTextFile")); + assertFalse(fs.getBoolean("writeTextFile")); + assertFalse(params.getJsonObject("clientCapabilities").getBoolean("terminal")); + assertEquals("camel-tui", params.getJsonObject("clientInfo").getString("name")); + } + + @Test + void unknownIncomingRequestIsAnsweredWithMethodNotFound() { + JsonObject params = new JsonObject(); + params.put("path", "/etc/passwd"); + JsonObject response = agent.sendRequest("fs/read_text_file", params); + assertNotNull(response.getJsonObject("error")); + assertEquals(AcpAgentClient.METHOD_NOT_FOUND, response.getJsonObject("error").getInteger("code")); + } + + @Test + void malformedLineIsSkippedAndReported() { + agent.sendRaw("this is not json"); + agent.sendRaw("[1,2,3]"); + AcpAgentClient.AgentInfo info = client.initialize(T); + assertEquals(1, info.protocolVersion()); + assertTrue(diagnostics.stream() + .anyMatch(d -> d.contains("not a JSON-RPC object") && d.contains("this is not json")), diagnostics.toString()); + assertTrue(diagnostics.stream() + .anyMatch(d -> d.contains("not a JSON-RPC object") && d.contains("[1,2,3]")), diagnostics.toString()); + } + + @Test + void requestAfterStreamClosedFailsFastWithConnectionError() { + agent.close(); + await().atMost(5, TimeUnit.SECONDS).until(() -> !client.isAlive()); + AcpAgentClient.AcpException e = assertThrows(AcpAgentClient.AcpException.class, () -> client.initialize(T)); + assertEquals(AcpAgentClient.CONNECTION, e.code()); + } + + @Test + void readerSurvivesARejectedIncomingRequestAfterClose() { + client.close(); + agent.sendRaw("{\"jsonrpc\":\"2.0\",\"id\":77,\"method\":\"session/request_permission\",\"params\":{}}"); + await().atMost(5, TimeUnit.SECONDS) + .until(() -> diagnostics.stream().anyMatch(d -> d.contains("Cannot handle message"))); + } + + @Test + void streamClosedFailsPendingRequests() { + agent.onRequest("session/prompt", params -> { + try { + new CountDownLatch(1).await(); // an agent that never answers: blocks until the pipe is closed + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return new JsonObject(); + }); + client.initialize(T); + String session = client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T); + Thread closer = new Thread(() -> { + agent.awaitReceived("session/prompt", T); + agent.close(); + }); + closer.setDaemon(true); + closer.start(); + AcpAgentClient.AcpException e = assertThrows(AcpAgentClient.AcpException.class, + () -> client.prompt(session, "hi", new NoopListener())); + assertEquals(AcpAgentClient.CONNECTION, e.code()); + assertTrue(e.getMessage().contains("agent exited"), e.getMessage()); + assertFalse(client.isAlive()); + } + + @Test + void newSessionPassesCwdAndTheTuiMcpServer() { + client.initialize(T); + String id = client.newSession(Path.of("/tmp/proj"), "http://127.0.0.1:8123/mcp", T); + assertTrue(id.startsWith("sess-")); + JsonObject params = agent.awaitReceived("session/new", T).getJsonObject("params"); + assertEquals("/tmp/proj", params.getString("cwd")); + JsonObject server = (JsonObject) params.getJsonArray("mcpServers").get(0); + assertEquals("http", server.getString("type")); + assertEquals("camel-tui", server.getString("name")); + assertEquals("http://127.0.0.1:8123/mcp", server.getString("url")); + } + + @Test + void authRequiredSurfacesAsAcpExceptionWithCode() { + client.initialize(T); + agent.failRequest("session/new", AcpAgentClient.AUTH_REQUIRED, "Authentication required"); + AcpAgentClient.AcpException e = assertThrows(AcpAgentClient.AcpException.class, + () -> client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T)); + assertEquals(AcpAgentClient.AUTH_REQUIRED, e.code()); + client.authenticate("agent-login", T); + assertEquals("agent-login", agent.awaitReceived("authenticate", T).getJsonObject("params").getString("methodId")); + } + + @Test + void promptStreamsUpdatesInOrderAndReturnsStopReason() { + agent.onRequest("session/prompt", params -> { + agent.sendNotification("session/update", update(params.getString("sessionId"), chunk("Hel"))); + agent.sendNotification("session/update", update(params.getString("sessionId"), chunk("lo"))); + JsonObject call = new JsonObject(); + call.put("sessionUpdate", "tool_call"); + call.put("toolCallId", "t1"); + call.put("title", "Read file"); + call.put("kind", "read"); + agent.sendNotification("session/update", update(params.getString("sessionId"), call)); + JsonObject done = new JsonObject(); + done.put("sessionUpdate", "tool_call_update"); + done.put("toolCallId", "t1"); + done.put("status", "completed"); + agent.sendNotification("session/update", update(params.getString("sessionId"), done)); + JsonObject usage = new JsonObject(); + usage.put("sessionUpdate", "usage_update"); + usage.put("used", 1234); + usage.put("size", 200000); + agent.sendNotification("session/update", update(params.getString("sessionId"), usage)); + JsonObject unknown = new JsonObject(); + unknown.put("sessionUpdate", "agent_thought_chunk"); + agent.sendNotification("session/update", update(params.getString("sessionId"), unknown)); + JsonObject r = new JsonObject(); + r.put("stopReason", "end_turn"); + return r; + }); + client.initialize(T); + String session = client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T); + RecordingListener listener = new RecordingListener(); + assertEquals("end_turn", client.prompt(session, "hi", listener)); + assertEquals(List.of("text:Hel", "text:lo", "call:t1:Read file:read", "update:t1:completed", "usage:1234"), + listener.events); + JsonObject prompt = agent.awaitReceived("session/prompt", T).getJsonObject("params"); + assertEquals(session, prompt.getString("sessionId")); + assertEquals("hi", ((JsonObject) prompt.getJsonArray("prompt").get(0)).getString("text")); + } + + @Test + void updatesForAnotherSessionAreIgnored() { + agent.onRequest("session/prompt", params -> { + agent.sendNotification("session/update", update("another-session", chunk("stale"))); + agent.sendNotification("session/update", update(params.getString("sessionId"), chunk("mine"))); + JsonObject r = new JsonObject(); + r.put("stopReason", "end_turn"); + return r; + }); + client.initialize(T); + String session = client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T); + RecordingListener listener = new RecordingListener(); + assertEquals("end_turn", client.prompt(session, "hi", listener)); + assertEquals(List.of("text:mine"), listener.events); + } + + @Test + void permissionRequestIsAnsweredWithTheHandlerChoice() { + client.setPermissionHandler((toolCall, options) -> "opt-allow"); + agent.onRequest("session/prompt", params -> { + JsonObject answer = agent.sendRequest("session/request_permission", permission("Write file", "edit")); + JsonObject r = new JsonObject(); + r.put("stopReason", answer.getJsonObject("result").getJsonObject("outcome").getString("optionId")); + return r; + }); + client.initialize(T); + String session = client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T); + assertEquals("opt-allow", client.prompt(session, "hi", new NoopListener())); + } + + @Test + void permissionHandlerReturningNullAnswersCancelled() { + client.setPermissionHandler((toolCall, options) -> null); + agent.onRequest("session/prompt", params -> { + JsonObject answer = agent.sendRequest("session/request_permission", permission("Write file", "edit")); + JsonObject r = new JsonObject(); + r.put("stopReason", answer.getJsonObject("result").getJsonObject("outcome").getString("outcome")); + return r; + }); + client.initialize(T); + String session = client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T); + assertEquals("cancelled", client.prompt(session, "hi", new NoopListener())); + } + + @Test + void cancelSendsNotificationAndPromptReturnsCancelled() { + agent.onRequest("session/prompt", params -> { + agent.awaitReceived("session/cancel", T); + JsonObject r = new JsonObject(); + r.put("stopReason", "cancelled"); + return r; + }); + client.initialize(T); + String session = client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T); + Thread canceller = new Thread(() -> { + agent.awaitReceived("session/prompt", T); + client.cancel(session); + }); + canceller.setDaemon(true); + canceller.start(); + assertEquals("cancelled", client.prompt(session, "hi", new NoopListener())); + assertEquals(session, agent.awaitReceived("session/cancel", T).getJsonObject("params").getString("sessionId")); + } + + @Test + void interruptedPromptSendsCancelAndReturnsCancelled() throws Exception { + agent.onRequest("session/prompt", params -> { + agent.awaitReceived("session/cancel", Duration.ofSeconds(30)); + JsonObject r = new JsonObject(); + r.put("stopReason", "cancelled"); + return r; + }); + client.initialize(T); + String session = client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T); + String[] result = new String[1]; + Thread caller = new Thread(() -> result[0] = client.prompt(session, "hi", new NoopListener())); + caller.start(); + agent.awaitReceived("session/prompt", T); + caller.interrupt(); + caller.join(5_000); + assertEquals("cancelled", result[0]); + agent.awaitReceived("session/cancel", T); + assertEquals(1, agent.receivedCount("session/cancel")); + } + + @Test + @Timeout(30) + void nextPromptWaitsForTheCancelledTurnToFinish() throws Exception { + CountDownLatch lateSent = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger calls = new AtomicInteger(); + agent.onRequest("session/prompt", params -> { + String sessionId = params.getString("sessionId"); + JsonObject r = new JsonObject(); + if (calls.incrementAndGet() == 1) { + agent.awaitReceived("session/cancel", Duration.ofSeconds(30)); + agent.sendNotification("session/update", update(sessionId, chunk("late-old"))); + lateSent.countDown(); + try { + release.await(30, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + r.put("stopReason", "cancelled"); + return r; + } + agent.sendNotification("session/update", update(sessionId, chunk("new"))); + r.put("stopReason", "end_turn"); + return r; + }); + client.initialize(T); + String session = client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T); + // the first turn runs on the test thread, like cancelSendsNotificationAndPromptReturnsCancelled: a piped + // stream breaks as soon as the thread that last wrote to it dies, so the writer has to outlive the test + Thread caller = Thread.currentThread(); + Thread esc = new Thread(() -> { + agent.awaitReceived("session/prompt", T); + caller.interrupt(); + }); + esc.setDaemon(true); + esc.start(); + assertEquals("cancelled", client.prompt(session, "one", new NoopListener())); + boolean interrupted = Thread.interrupted(); + assertTrue(interrupted, "prompt leaves the caller interrupted"); + RecordingListener second = new RecordingListener(); + String[] result = new String[1]; + Thread next = new Thread(() -> result[0] = client.prompt(session, "two", second)); + next.start(); + assertTrue(lateSent.await(5, TimeUnit.SECONDS)); + assertEquals(1, agent.receivedCount("session/prompt"), "the second prompt waits for the cancelled turn"); + release.countDown(); + next.join(10_000); + assertEquals("end_turn", result[0]); + assertEquals(List.of("text:new"), second.events); + assertEquals(2, agent.receivedCount("session/prompt")); + } + + @Test + @Timeout(30) + void newSessionForgetsTheCancelledTurn() { + CountDownLatch never = new CountDownLatch(1); + AtomicInteger calls = new AtomicInteger(); + agent.onRequest("session/prompt", params -> { + JsonObject r = new JsonObject(); + if (calls.incrementAndGet() == 1) { + try { + never.await(30, TimeUnit.SECONDS); // the cancelled turn is never answered + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + r.put("stopReason", "cancelled"); + return r; + } + r.put("stopReason", "end_turn"); + return r; + }); + client.initialize(T); + String first = client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T); + Thread caller = Thread.currentThread(); + Thread esc = new Thread(() -> { + agent.awaitReceived("session/prompt", T); + caller.interrupt(); + }); + esc.setDaemon(true); + esc.start(); + assertEquals("cancelled", client.prompt(first, "one", new NoopListener())); + Thread.interrupted(); + String second = client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T); + assertEquals("end_turn", client.prompt(second, "two", new NoopListener())); + assertFalse(diagnostics.stream().anyMatch(d -> d.contains("cancelled turn")), diagnostics.toString()); + } + + @Test + @EnabledOnOs({ OS.LINUX, OS.MAC }) + void spawnedProcessExitReportsCodeAndStderr() throws Exception { + AcpAgentClient spawned = AcpAgentClient.spawn( + List.of("sh", "-c", "echo boom >&2; exit 3"), Path.of("."), diagnostics::add); + try { + AcpAgentClient.AcpException e = assertThrows(AcpAgentClient.AcpException.class, + () -> spawned.initialize(T)); + assertTrue(e.getMessage().contains("code 3"), e.getMessage()); + assertTrue(e.getMessage().contains("boom"), e.getMessage()); + assertFalse(spawned.isAlive()); + } finally { + spawned.close(); + } + } + + @Test + void availableCommandsUpdateIsStoredEvenWithoutAPromptInFlight() { + agent.onRequest("session/new", params -> { + JsonObject review = new JsonObject(); + review.put("name", "review"); + review.put("description", "Review the current changes"); + JsonObject input = new JsonObject(); + input.put("hint", "focus area"); + review.put("input", input); + JsonObject commit = new JsonObject(); + commit.put("name", "commit"); + commit.put("description", "Commit staged changes"); + JsonArray commands = new JsonArray(); + commands.add(review); + commands.add(commit); + JsonObject update = new JsonObject(); + update.put("sessionUpdate", "available_commands_update"); + update.put("availableCommands", commands); + JsonObject params2 = new JsonObject(); + params2.put("sessionId", "sess-1"); + params2.put("update", update); + agent.sendNotification("session/update", params2); + JsonObject r = new JsonObject(); + r.put("sessionId", "sess-1"); + return r; + }); + client.initialize(T); + client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T); + await().atMost(5, TimeUnit.SECONDS).until(() -> client.availableCommands().size() == 2); + AcpAgentClient.AgentCommand review = client.availableCommands().get(0); + assertEquals("review", review.name()); + assertEquals("Review the current changes", review.description()); + assertEquals("focus area", review.hint()); + assertNull(client.availableCommands().get(1).hint()); + } + + @Test + void commandsFromAnotherSessionAreIgnored() { + client.initialize(T); + String session = client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T); + agent.sendNotification("session/update", update("sess-OLD", commandsUpdate("review"))); + agent.sendNotification("session/update", update(session, commandsUpdate("commit"))); + await().atMost(5, TimeUnit.SECONDS).until(() -> client.availableCommands().size() == 1); + assertEquals("commit", client.availableCommands().get(0).name()); + } + + @Test + void commandsSentWhileTheSessionIsCreatedReplaceTheOldOnes() { + AtomicInteger sessions = new AtomicInteger(); + agent.onRequest("session/new", params -> { + String id = "sess-" + sessions.incrementAndGet(); + agent.sendNotification("session/update", update(id, commandsUpdate("cmd-" + id))); + JsonObject r = new JsonObject(); + r.put("sessionId", id); + return r; + }); + client.initialize(T); + client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T); + assertEquals("cmd-sess-1", client.availableCommands().get(0).name()); + client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T); + assertEquals(1, client.availableCommands().size()); + assertEquals("cmd-sess-2", client.availableCommands().get(0).name()); + } + + private static JsonObject update(String sessionId, JsonObject update) { + JsonObject params = new JsonObject(); + params.put("sessionId", sessionId); + params.put("update", update); + return params; + } + + /** An available_commands_update carrying a single command with that name. */ + private static JsonObject commandsUpdate(String name) { + JsonObject command = new JsonObject(); + command.put("name", name); + JsonArray commands = new JsonArray(); + commands.add(command); + JsonObject update = new JsonObject(); + update.put("sessionUpdate", "available_commands_update"); + update.put("availableCommands", commands); + return update; + } + + private static JsonObject chunk(String text) { + JsonObject content = new JsonObject(); + content.put("type", "text"); + content.put("text", text); + JsonObject update = new JsonObject(); + update.put("sessionUpdate", "agent_message_chunk"); + update.put("content", content); + return update; + } + + static JsonObject permission(String title, String kind) { + JsonObject toolCall = new JsonObject(); + toolCall.put("toolCallId", "t9"); + toolCall.put("title", title); + toolCall.put("kind", kind); + JsonArray options = new JsonArray(); + options.add(option("opt-allow", "Allow", "allow_once")); + options.add(option("opt-always", "Always allow", "allow_always")); + options.add(option("opt-reject", "Reject", "reject_once")); + JsonObject params = new JsonObject(); + params.put("sessionId", "sess"); + params.put("toolCall", toolCall); + params.put("options", options); + return params; + } + + static JsonObject option(String id, String name, String kind) { + JsonObject o = new JsonObject(); + o.put("optionId", id); + o.put("name", name); + o.put("kind", kind); + return o; + } + + static final class RecordingListener implements AcpAgentClient.Listener { + final List events = new CopyOnWriteArrayList<>(); + + @Override + public void onTextChunk(String text) { + events.add("text:" + text); + } + + @Override + public void onToolCall(String toolCallId, String title, String kind, JsonObject rawInput) { + events.add("call:" + toolCallId + ":" + title + ":" + kind); + } + + @Override + public void onToolCallUpdate(String toolCallId, String status, String contentText) { + events.add("update:" + toolCallId + ":" + status); + } + + @Override + public void onUsage(long used, long size) { + events.add("usage:" + used); + } + } + + static final class NoopListener implements AcpAgentClient.Listener { + @Override + public void onTextChunk(String text) { + } + + @Override + public void onToolCall(String toolCallId, String title, String kind, JsonObject rawInput) { + } + + @Override + public void onToolCallUpdate(String toolCallId, String status, String contentText) { + } + + @Override + public void onUsage(long used, long size) { + } + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpHeaderStripTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpHeaderStripTest.java new file mode 100644 index 0000000000000..3c8798815194d --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpHeaderStripTest.java @@ -0,0 +1,66 @@ +/* + * 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.nio.file.Path; + +import dev.tamboui.buffer.Buffer; +import dev.tamboui.layout.Rect; +import dev.tamboui.style.Color; +import dev.tamboui.terminal.Frame; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AcpHeaderStripTest { + + private static AcpHeaderStrip.Model model(int commands) { + return new AcpHeaderStrip.Model( + "IBM Bob (ACP)", "◆", Color.rgb(0x0F, 0x62, 0xFE), + "bob-shell 2.0.2", "58b65aea-1234", Path.of(System.getProperty("user.home"), "Work", "camel"), commands); + } + + @Test + void glyphModeRendersGlyphAndMetadata() { + AcpHeaderStrip strip = new AcpHeaderStrip(); + Rect area = new Rect(0, 0, 100, AcpHeaderStrip.ROWS); + Buffer buffer = Buffer.empty(area); + strip.render(Frame.forTesting(buffer), area, model(24)); + String rendered = TuiTestHelper.bufferToString(buffer); + assertTrue(rendered.contains("◆ IBM Bob (ACP) · bob-shell 2.0.2"), rendered); + assertTrue(rendered.contains("session 58b65aea"), rendered); + assertTrue(rendered.contains("~/Work/camel"), rendered); + assertTrue(rendered.contains("24 commands"), rendered); + } + + @Test + void narrowAreaRendersNothing() { + AcpHeaderStrip strip = new AcpHeaderStrip(); + Rect area = new Rect(0, 0, 19, AcpHeaderStrip.ROWS); + Buffer buffer = Buffer.empty(area); + strip.render(Frame.forTesting(buffer), area, model(3)); + assertTrue(TuiTestHelper.bufferToString(buffer).isBlank(), "nothing fits below 20 columns"); + } + + @Test + void metaLineAndHomeRelativePath() { + assertEquals("~/Work/camel", AcpHeaderStrip.homeRelative(Path.of(System.getProperty("user.home"), "Work", "camel"))); + assertEquals("/opt/x", AcpHeaderStrip.homeRelative(Path.of("/opt/x"))); + assertEquals("session 58b65aea · ~/Work/camel · 1 command · /agent: lists them", AcpHeaderStrip.metaLine(model(1))); + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpPermissionPopupTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpPermissionPopupTest.java new file mode 100644 index 0000000000000..0e4b27e9a58c9 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpPermissionPopupTest.java @@ -0,0 +1,160 @@ +/* + * 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 dev.tamboui.buffer.Buffer; +import dev.tamboui.layout.Rect; +import dev.tamboui.terminal.Frame; +import dev.tamboui.text.Span; +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.tui.event.KeyModifiers; +import dev.tamboui.tui.event.MouseButton; +import dev.tamboui.tui.event.MouseEvent; +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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AcpPermissionPopupTest { + + private static List options() { + return List.of( + AcpAgentClientTest.option("opt-allow", "Allow once", "allow_once"), + AcpAgentClientTest.option("opt-always", "Always allow", "allow_always"), + AcpAgentClientTest.option("opt-reject", "Reject", "reject_once")); + } + + private static JsonObject toolCall() { + JsonObject rawInput = new JsonObject(); + rawInput.put("file_path", "/tmp/route.yaml"); + JsonObject toolCall = new JsonObject(); + toolCall.put("title", "Write /tmp/route.yaml"); + toolCall.put("kind", "edit"); + toolCall.put("rawInput", rawInput); + return toolCall; + } + + @Test + void rendersTitleKindInputAndOptions() { + AcpPermissionPopup popup = new AcpPermissionPopup(); + popup.open(toolCall(), options()); + Rect area = new Rect(0, 0, 100, 30); + Buffer buffer = Buffer.empty(area); + popup.render(Frame.forTesting(buffer), area); + String rendered = TuiTestHelper.bufferToString(buffer); + assertTrue(rendered.contains("Write /tmp/route.yaml")); + assertTrue(rendered.contains("edit")); + assertTrue(rendered.contains("file_path")); + assertTrue(rendered.contains("Allow once")); + assertTrue(rendered.contains("Always allow")); + assertTrue(rendered.contains("Reject")); + } + + @Test + void optionsStayVisibleOnAShortScreen() { + AcpPermissionPopup popup = new AcpPermissionPopup(); + popup.open(toolCall(), options()); + Rect area = new Rect(0, 0, 80, 8); + Buffer buffer = Buffer.empty(area); + popup.render(Frame.forTesting(buffer), area); + String rendered = TuiTestHelper.bufferToString(buffer); + assertTrue(rendered.contains("Write /tmp/route.yaml"), rendered); + assertTrue(rendered.contains("Allow once"), rendered); + assertTrue(rendered.contains("Always allow"), rendered); + assertTrue(rendered.contains("Reject"), rendered); + } + + @Test + void enterSelectsTheHighlightedOption() { + AcpPermissionPopup popup = new AcpPermissionPopup(); + popup.open(toolCall(), options()); + popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.NONE)); + popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE)); + AcpPermissionPopup.Decision decision = popup.consumeDecision(); + assertNotNull(decision); + assertEquals("opt-always", decision.optionId()); + assertFalse(popup.isVisible()); + assertNull(popup.consumeDecision(), "decision is consumed once"); + } + + @Test + void escapePicksRejectOnceWhenOffered() { + AcpPermissionPopup popup = new AcpPermissionPopup(); + popup.open(toolCall(), options()); + popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE, KeyModifiers.NONE)); + assertEquals("opt-reject", popup.consumeDecision().optionId()); + } + + @Test + void escapeAnswersCancelledWhenNoRejectOptionExists() { + AcpPermissionPopup popup = new AcpPermissionPopup(); + popup.open(toolCall(), List.of(AcpAgentClientTest.option("opt-allow", "Allow", "allow_once"))); + popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE, KeyModifiers.NONE)); + AcpPermissionPopup.Decision decision = popup.consumeDecision(); + assertNotNull(decision); + assertNull(decision.optionId()); + } + + @Test + void footerOffersSelectCancelTurnAndReject() { + AcpPermissionPopup popup = new AcpPermissionPopup(); + popup.open(toolCall(), options()); + List spans = new ArrayList<>(); + popup.renderFooter(spans); + String footer = spans.stream().map(Span::content).reduce("", String::concat); + assertTrue(footer.contains("Enter")); + assertTrue(footer.contains("Ctrl+C")); + assertTrue(footer.contains("cancel turn")); + assertTrue(footer.contains("Esc")); + } + + @Test + void clickOnAnOptionRowSelectsThatOption() { + AcpPermissionPopup popup = new AcpPermissionPopup(); + popup.open(toolCall(), options()); + Rect area = new Rect(0, 0, 100, 30); + Buffer buffer = Buffer.empty(area); + popup.render(Frame.forTesting(buffer), area); + Rect listRect = popup.listRectForTesting(); + popup.handleMouseEvent(MouseEvent.press(MouseButton.LEFT, listRect.x() + 1, listRect.y() + 1)); + AcpPermissionPopup.Decision decision = popup.consumeDecision(); + assertNotNull(decision); + assertEquals("opt-always", decision.optionId()); + assertFalse(popup.isVisible()); + } + + @Test + void clickOutsideTheOptionRowsDoesNothing() { + AcpPermissionPopup popup = new AcpPermissionPopup(); + popup.open(toolCall(), options()); + Rect area = new Rect(0, 0, 100, 30); + Buffer buffer = Buffer.empty(area); + popup.render(Frame.forTesting(buffer), area); + Rect listRect = popup.listRectForTesting(); + popup.handleMouseEvent(MouseEvent.press(MouseButton.LEFT, listRect.x(), listRect.y() - 2)); + assertNull(popup.consumeDecision()); + assertTrue(popup.isVisible()); + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelAcpTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelAcpTest.java new file mode 100644 index 0000000000000..9601c22d08841 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelAcpTest.java @@ -0,0 +1,863 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.io.IOException; +import java.nio.file.Path; +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import dev.tamboui.buffer.Buffer; +import dev.tamboui.layout.Rect; +import dev.tamboui.terminal.Frame; +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.tui.event.KeyModifiers; +import org.apache.camel.dsl.jbang.core.commands.LlmClient; +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 org.junit.jupiter.api.parallel.Isolated; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +@Isolated +class AiPanelAcpTest { + + private static final Duration T = Duration.ofSeconds(5); + + private String originalHome; + private FakeAcpAgent agent; + + @BeforeEach + void isolateHome(@TempDir Path tempDir) { + originalHome = CommandLineHelper.getHomeDir().toString(); + CommandLineHelper.useHomeDir(tempDir.toString()); + } + + @AfterEach + void restoreHome() { + CommandLineHelper.useHomeDir(originalHome); + } + + @AfterEach + void closeAgent() { + if (agent != null) { + agent.close(); + } + } + + private static void type(AiPanel panel, String text) { + for (char ch : text.toCharArray()) { + panel.handleKeyEvent(KeyEvent.ofChar(ch)); + } + } + + private static void enter(AiPanel panel) { + panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE)); + } + + private static boolean hasEntry(AiPanel panel, AiRole role, String fragment) { + return panel.conversationForTesting().stream() + .anyMatch(e -> e.role() == role && e.text().contains(fragment)); + } + + /** Text of the last conversation entry with that role. */ + private static String lastEntry(AiPanel panel, AiRole role) { + return panel.conversationForTesting().stream() + .filter(e -> e.role() == role) + .reduce((first, last) -> last) + .map(AiPanel.ConversationEntry::text) + .orElseGet(() -> fail("no " + role + " entry in the conversation")); + } + + /** Panel wired to a fresh fake agent, with the Claude preset selected. */ + private AiPanel acpPanel() throws IOException { + agent = new FakeAcpAgent(); + AiPanel panel = new AiPanel(); + panel.setToolRegistryForTesting(new TuiToolRegistry(null)); + panel.setAcpClientFactoryForTesting((preset, cwd) -> { + AcpAgentClient client = new AcpAgentClient(agent.clientInput(), agent.clientOutput(), s -> { + }); + client.start(); + return client; + }); + panel.setMcpUrlSupplierForTestingOrRuntime(() -> "http://127.0.0.1:4242/mcp"); + panel.open(); + panel.selectProviderForTesting("acp:claude"); + return panel; + } + + private static void ask(AiPanel panel, String text) { + type(panel, text); + enter(panel); + } + + private static void awaitIdle(AiPanel panel) { + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> assertFalse(panel.isThinkingForTesting())); + } + + /** Wraps an update for the session the prompt request carried, like a real agent does. */ + private static JsonObject update(JsonObject promptParams, JsonObject update) { + JsonObject params = new JsonObject(); + params.put("sessionId", promptParams.getString("sessionId")); + params.put("update", update); + return params; + } + + private static JsonObject chunk(String text) { + JsonObject content = new JsonObject(); + content.put("type", "text"); + content.put("text", text); + JsonObject u = new JsonObject(); + u.put("sessionUpdate", "agent_message_chunk"); + u.put("content", content); + return u; + } + + private static JsonObject stop(String reason) { + JsonObject r = new JsonObject(); + r.put("stopReason", reason); + return r; + } + + private static String promptText(JsonObject promptRequest) { + JsonArray prompt = promptRequest.getJsonObject("params").getJsonArray("prompt"); + return ((JsonObject) prompt.get(0)).getString("text"); + } + + private static JsonObject permissionParams(String name, String title) { + return permissionParams(name, title, + AcpAgentClientTest.option("opt-allow", "Allow once", "allow_once"), + AcpAgentClientTest.option("opt-always", "Always allow", "allow_always"), + AcpAgentClientTest.option("opt-reject", "Reject", "reject_once")); + } + + private static JsonObject permissionParams(String name, String title, JsonObject... offered) { + JsonObject toolCall = new JsonObject(); + toolCall.put("toolCallId", "t9"); + if (name != null) { + toolCall.put("name", name); + } + toolCall.put("title", title); + toolCall.put("kind", "other"); + JsonArray options = new JsonArray(); + for (JsonObject option : offered) { + options.add(option); + } + JsonObject params = new JsonObject(); + params.put("sessionId", "sess"); + params.put("toolCall", toolCall); + params.put("options", options); + return params; + } + + /** Makes the fake agent advertise a "review" command via available_commands_update from its session/new handler. */ + private void advertiseReviewCommand() { + JsonObject review = new JsonObject(); + review.put("name", "review"); + review.put("description", "Review the current changes"); + JsonObject input = new JsonObject(); + input.put("hint", "focus area"); + review.put("input", input); + JsonArray commands = new JsonArray(); + commands.add(review); + advertise(commands); + } + + /** Same, for commands that only carry a name and a description. */ + private void advertiseCommands(String... names) { + JsonArray commands = new JsonArray(); + for (String name : names) { + JsonObject command = new JsonObject(); + command.put("name", name); + command.put("description", "The agent's own /" + name); + commands.add(command); + } + advertise(commands); + } + + private void advertise(JsonArray commands) { + agent.onRequest("session/new", params -> { + JsonObject update = new JsonObject(); + update.put("sessionUpdate", "available_commands_update"); + update.put("availableCommands", commands); + JsonObject notifyParams = new JsonObject(); + notifyParams.put("sessionId", "sess-cmd"); + notifyParams.put("update", update); + agent.sendNotification("session/update", notifyParams); + JsonObject r = new JsonObject(); + r.put("sessionId", "sess-cmd"); + return r; + }); + } + + /** Prompt handler that asks permission once and reports the client's answer as the stop reason. */ + private void askPermissionDuringPrompt(JsonObject permission) { + agent.onRequest("session/prompt", params -> { + JsonObject answer = agent.sendRequest("session/request_permission", permission); + JsonObject outcome = answer.getJsonObject("result").getJsonObject("outcome"); + String chosen = outcome.getString("optionId"); + return stop(chosen != null ? chosen : outcome.getString("outcome")); + }); + } + + @Test + void selectingAnAcpProviderNeedsNoLlmClient() { + AiPanel panel = new AiPanel(); + panel.open(); + panel.selectProviderForTesting("acp:claude"); + assertTrue(panel.isAcpProviderForTesting()); + assertTrue(hasEntry(panel, AiRole.SYSTEM, "Selected Claude Code (ACP)")); + } + + @Test + void customProviderWithoutCommandIsRejected() { + AiPanel panel = new AiPanel(); + panel.open(); + panel.selectProviderForTesting("acp:custom"); + assertFalse(panel.isAcpProviderForTesting()); + assertTrue(hasEntry(panel, AiRole.ERROR, "camel.tui.ai.acp.command")); + } + + @Test + void modelCommandExplainsThatTheAgentOwnsTheModel() { + AiPanel panel = new AiPanel(); + panel.open(); + panel.selectProviderForTesting("acp:codex"); + type(panel, "/model gpt-5"); + enter(panel); + assertTrue(hasEntry(panel, AiRole.ERROR, "configured in the agent")); + } + + @Test + void titleShowsTheAgentLabel() { + AiPanel panel = new AiPanel(); + panel.open(); + panel.selectProviderForTesting("acp:opencode"); + Rect area = new Rect(0, 0, 100, 20); + Buffer buffer = Buffer.empty(area); + panel.render(Frame.forTesting(buffer), area); + assertTrue(TuiTestHelper.bufferToString(buffer).contains("OpenCode (ACP)")); + } + + @Test + void firstPromptStartsTheAgentPassesTheMcpServerAndStreamsTheAnswer() throws Exception { + AiPanel panel = acpPanel(); + agent.onRequest("session/prompt", params -> { + agent.sendNotification("session/update", update(params, chunk("Hel"))); + agent.sendNotification("session/update", update(params, chunk("lo"))); + return stop("end_turn"); + }); + ask(panel, "what is wrong?"); + awaitIdle(panel); + assertTrue(hasEntry(panel, AiRole.SYSTEM, "Starting Claude Code (ACP)")); + assertTrue(hasEntry(panel, AiRole.ASSISTANT, "Hello")); + assertEquals(1, agent.awaitReceived("initialize", T).getJsonObject("params").getInteger("protocolVersion")); + JsonObject session = agent.awaitReceived("session/new", T).getJsonObject("params"); + JsonObject server = (JsonObject) session.getJsonArray("mcpServers").get(0); + assertEquals("http://127.0.0.1:4242/mcp", server.getString("url")); + String text = promptText(agent.awaitReceived("session/prompt", T)); + assertTrue(text.contains("Apache Camel assistant"), "first prompt carries the TUI preamble"); + assertTrue(text.endsWith("what is wrong?")); + Rect area = new Rect(0, 0, 120, 20); + Buffer buffer = Buffer.empty(area); + panel.render(Frame.forTesting(buffer), area); + assertTrue(TuiTestHelper.bufferToString(buffer).contains("fake-agent 1.2.3"), "title shows the agent"); + } + + @Test + void laterPromptsSkipThePreamble() throws Exception { + AiPanel panel = acpPanel(); + ask(panel, "first"); + awaitIdle(panel); + ask(panel, "again"); + awaitIdle(panel); + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> assertEquals(2, agent.receivedCount("session/prompt"))); + assertEquals("again", promptText(agent.received("session/prompt").get(1))); + assertEquals(1, agent.receivedCount("session/new"), "one session for both prompts"); + } + + @Test + void toolCallsBecomeStatusLinesThatAreUpdatedInPlace() throws Exception { + AiPanel panel = acpPanel(); + agent.onRequest("session/prompt", params -> { + JsonObject call = new JsonObject(); + call.put("sessionUpdate", "tool_call"); + call.put("toolCallId", "t1"); + call.put("title", "Read file"); + call.put("kind", "read"); + agent.sendNotification("session/update", update(params, call)); + JsonObject done = new JsonObject(); + done.put("sessionUpdate", "tool_call_update"); + done.put("toolCallId", "t1"); + done.put("status", "completed"); + agent.sendNotification("session/update", update(params, done)); + agent.sendNotification("session/update", update(params, chunk("done"))); + return stop("end_turn"); + }); + ask(panel, "read it"); + awaitIdle(panel); + assertTrue(hasEntry(panel, AiRole.SYSTEM, TuiIcons.CHECK + " Read file")); + assertFalse(hasEntry(panel, AiRole.SYSTEM, TuiIcons.GEAR + " Read file"), "running marker replaced"); + assertTrue(hasEntry(panel, AiRole.ASSISTANT, "done")); + } + + @Test + void escapeCancelsTheTurn() throws Exception { + AiPanel panel = acpPanel(); + agent.onRequest("session/prompt", params -> { + agent.awaitReceived("session/cancel", Duration.ofSeconds(30)); + return stop("cancelled"); + }); + ask(panel, "slow one"); + agent.awaitReceived("session/prompt", T); + panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE, KeyModifiers.NONE)); + awaitIdle(panel); + assertTrue(hasEntry(panel, AiRole.SYSTEM, "(cancelled)")); + assertTrue(agent.receivedCount("session/cancel") >= 1); + } + + @Test + void clearOpensANewSessionOnTheNextPrompt() throws Exception { + AiPanel panel = acpPanel(); + ask(panel, "one"); + awaitIdle(panel); + String first = panel.acpSessionIdForTesting(); + ask(panel, "/clear"); + ask(panel, "two"); + awaitIdle(panel); + assertEquals(2, agent.receivedCount("session/new")); + assertNotEquals(first, panel.acpSessionIdForTesting()); + assertTrue(promptText(agent.awaitReceived("session/prompt", T)).contains("Apache Camel assistant")); + } + + @Test + void aJsonRpcErrorKeepsTheSessionAlive() throws Exception { + AiPanel panel = acpPanel(); + agent.failRequest("session/prompt", -32603, "boom"); + ask(panel, "hi"); + awaitIdle(panel); + assertTrue(hasEntry(panel, AiRole.ERROR, "boom")); + ask(panel, "again"); + awaitIdle(panel); + assertEquals(2, agent.receivedCount("session/prompt"), "the second question reaches the same agent"); + assertEquals(1, agent.receivedCount("session/new"), "the session survives a JSON-RPC error"); + assertEquals(1, agent.receivedCount("initialize"), "the agent process survives a JSON-RPC error"); + } + + @Test + void authRequiredTriggersOneAuthenticateAndARetry() throws Exception { + AiPanel panel = acpPanel(); + agent.onRequest("initialize", params -> { + JsonObject result = FakeAcpAgent.defaultInitializeResult(); + JsonObject method = new JsonObject(); + method.put("id", "agent-login"); + method.put("name", "Agent login"); + JsonArray methods = new JsonArray(); + methods.add(method); + result.put("authMethods", methods); + return result; + }); + agent.failRequest("session/new", AcpAgentClient.AUTH_REQUIRED, "Authentication required"); + ask(panel, "hi"); + awaitIdle(panel); + assertEquals(1, agent.receivedCount("authenticate")); + assertEquals(2, agent.receivedCount("session/new")); + assertEquals("agent-login", agent.awaitReceived("authenticate", T).getJsonObject("params").getString("methodId")); + assertTrue(hasEntry(panel, AiRole.SYSTEM, "Authenticating with fake-agent")); + assertFalse(hasEntry(panel, AiRole.ERROR, "Authentication")); + } + + @Test + void authRequiredWithOnlyTerminalMethodsShowsTheLoginHint() throws Exception { + AiPanel panel = acpPanel(); + agent.onRequest("initialize", params -> { + JsonObject result = FakeAcpAgent.defaultInitializeResult(); + JsonObject meta = new JsonObject(); + meta.put("type", "terminal"); + JsonObject method = new JsonObject(); + method.put("id", "claude-login"); + method.put("name", "Log in with Claude"); + method.put("_meta", meta); + JsonArray methods = new JsonArray(); + methods.add(method); + result.put("authMethods", methods); + return result; + }); + agent.failRequest("session/new", AcpAgentClient.AUTH_REQUIRED, "Authentication required"); + ask(panel, "hi"); + awaitIdle(panel); + assertEquals(0, agent.receivedCount("authenticate")); + assertTrue(hasEntry(panel, AiRole.ERROR, "ANTHROPIC_API_KEY")); + } + + @Test + void protocolVersionMismatchIsReported() throws Exception { + AiPanel panel = acpPanel(); + agent.onRequest("initialize", params -> { + JsonObject result = FakeAcpAgent.defaultInitializeResult(); + result.put("protocolVersion", 2); + return result; + }); + ask(panel, "hi"); + awaitIdle(panel); + assertTrue(hasEntry(panel, AiRole.ERROR, "protocol version")); + assertEquals(0, agent.receivedCount("session/new")); + } + + @Test + void missingHttpMcpCapabilityIsReported() throws Exception { + AiPanel panel = acpPanel(); + agent.onRequest("initialize", params -> { + JsonObject result = FakeAcpAgent.defaultInitializeResult(); + result.getJsonObject("agentCapabilities").getJsonObject("mcpCapabilities").put("http", false); + return result; + }); + ask(panel, "hi"); + awaitIdle(panel); + assertTrue(hasEntry(panel, AiRole.ERROR, "HTTP MCP")); + } + + @Test + void refusalStopReasonIsAnError() throws Exception { + AiPanel panel = acpPanel(); + agent.onRequest("session/prompt", params -> stop("refusal")); + ask(panel, "hi"); + awaitIdle(panel); + assertTrue(hasEntry(panel, AiRole.ERROR, "refused")); + } + + /** Makes the fake agent report a context usage of that size for the next turn. */ + private void reportUsage(long used, long size) { + agent.onRequest("session/prompt", params -> { + JsonObject usage = new JsonObject(); + usage.put("sessionUpdate", "usage_update"); + usage.put("used", used); + usage.put("size", size); + agent.sendNotification("session/update", update(params, usage)); + agent.sendNotification("session/update", update(params, chunk("ok"))); + return stop("end_turn"); + }); + } + + @Test + void usageUpdateFeedsTheTokenCounter() throws Exception { + AiPanel panel = acpPanel(); + reportUsage(1234, 200000); + ask(panel, "hi"); + awaitIdle(panel); + assertEquals(1234, panel.sessionTotalTokensForTesting()); + assertArrayEquals(new long[] { 1234, 200000 }, panel.acpContextForTesting()); + + // the agent reports what is in its context now, so the second turn only adds its own 66 tokens + reportUsage(1300, 200000); + ask(panel, "and now?"); + awaitIdle(panel); + assertEquals(1300, panel.sessionTotalTokensForTesting(), "1234 + the 66 this turn added"); + assertArrayEquals(new long[] { 1300, 200000 }, panel.acpContextForTesting()); + ask(panel, "/context"); + String text = lastEntry(panel, AiRole.SYSTEM); + assertTrue(text.contains("Context: " + LlmClient.formatTokens(1300) + " of " + LlmClient.formatTokens(200000)), + text); + + // the agent compacted: the context shrank, which is not a negative token spend + reportUsage(900, 200000); + ask(panel, "still there?"); + awaitIdle(panel); + assertEquals(1300, panel.sessionTotalTokensForTesting(), "a smaller context spends nothing"); + assertArrayEquals(new long[] { 900, 200000 }, panel.acpContextForTesting()); + } + + @Test + void missingMcpServerIsReportedWithoutSpawning() throws Exception { + AiPanel panel = acpPanel(); + panel.setMcpUrlSupplierForTestingOrRuntime(() -> { + throw new IOException("bind failed"); + }); + ask(panel, "hi"); + awaitIdle(panel); + assertTrue(hasEntry(panel, AiRole.ERROR, "MCP server")); + assertEquals(0, agent.receivedCount("initialize")); + } + + @Test + void tuiToolCallsAreAutoApprovedByName() throws Exception { + AiPanel panel = acpPanel(); + askPermissionDuringPrompt(permissionParams("mcp__camel-tui__tui_get_state", "tui_get_state")); + ask(panel, "hi"); + awaitIdle(panel); + assertFalse(panel.isPermissionPopupVisibleForTesting()); + assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-always)"), "allow_always preferred"); + } + + @Test + void tuiToolCallsAreAutoApprovedByTitle() throws Exception { + AiPanel panel = acpPanel(); + askPermissionDuringPrompt(permissionParams(null, "tui_get_state (camel-tui MCP Server)")); + ask(panel, "hi"); + awaitIdle(panel); + assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-always)")); + } + + @Test + void mutatingTuiToolOpensThePopupEvenByName() throws Exception { + AiPanel panel = acpPanel(); + askPermissionDuringPrompt(permissionParams("mcp__camel-tui__tui_control", "tui_control")); + ask(panel, "hi"); + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> assertTrue(panel.isPermissionPopupVisibleForTesting())); + panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE, KeyModifiers.NONE)); + awaitIdle(panel); + assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-reject)"), "tui_control is not read-only"); + } + + @Test + void executeKindIsNotAutoApprovedEvenForARegisteredTuiTool() throws Exception { + AiPanel panel = acpPanel(); + JsonObject permission = permissionParams("mcp__camel-tui__tui_get_state", "tui_get_state"); + permission.getJsonObject("toolCall").put("kind", "execute"); + askPermissionDuringPrompt(permission); + ask(panel, "hi"); + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> assertTrue(panel.isPermissionPopupVisibleForTesting())); + panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE, KeyModifiers.NONE)); + awaitIdle(panel); + assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-reject)"), "a shell call is never a TUI tool call"); + } + + @Test + void fileEditWithCamelTuiInThePathIsNotAutoApproved() throws Exception { + AiPanel panel = acpPanel(); + JsonObject permission = permissionParams(null, "Edit /tmp/camel-tui/route.yaml"); + permission.getJsonObject("toolCall").put("kind", "edit"); + askPermissionDuringPrompt(permission); + ask(panel, "hi"); + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> assertTrue(panel.isPermissionPopupVisibleForTesting())); + panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE, KeyModifiers.NONE)); + awaitIdle(panel); + assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-reject)"), "a path is not a tool identity"); + } + + @Test + void unregisteredToolWithCamelTuiTitleIsNotAutoApproved() throws Exception { + AiPanel panel = acpPanel(); + askPermissionDuringPrompt(permissionParams(null, "rm -rf / (camel-tui MCP Server)")); + ask(panel, "hi"); + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> assertTrue(panel.isPermissionPopupVisibleForTesting())); + panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE, KeyModifiers.NONE)); + awaitIdle(panel); + assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-reject)"), "the TUI registers no such tool"); + } + + @Test + void tuiToolWithoutAllowAlwaysFallsBackToAllowOnce() throws Exception { + AiPanel panel = acpPanel(); + askPermissionDuringPrompt(permissionParams("mcp__camel-tui__tui_get_state", "tui_get_state", + AcpAgentClientTest.option("opt-allow", "Allow once", "allow_once"), + AcpAgentClientTest.option("opt-reject", "Reject", "reject_once"))); + ask(panel, "hi"); + awaitIdle(panel); + assertFalse(panel.isPermissionPopupVisibleForTesting()); + assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-allow)")); + } + + @Test + void tuiToolWithNoAllowOptionsPicksTheFirst() throws Exception { + AiPanel panel = acpPanel(); + askPermissionDuringPrompt(permissionParams("mcp__camel-tui__tui_get_state", "tui_get_state", + AcpAgentClientTest.option("opt-reject", "Reject", "reject_once"))); + ask(panel, "hi"); + awaitIdle(panel); + assertFalse(panel.isPermissionPopupVisibleForTesting()); + assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-reject)")); + } + + @Test + void otherToolCallsOpenThePopupAndEnterAnswers() throws Exception { + AiPanel panel = acpPanel(); + askPermissionDuringPrompt(permissionParams("Bash", "rm -rf build")); + ask(panel, "hi"); + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> assertTrue(panel.isPermissionPopupVisibleForTesting())); + assertTrue(panel.isThinkingForTesting(), "turn is still in progress while the popup waits"); + enter(panel); + awaitIdle(panel); + assertFalse(panel.isPermissionPopupVisibleForTesting()); + assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-allow)")); + } + + @Test + void escapeOnThePopupRejects() throws Exception { + AiPanel panel = acpPanel(); + askPermissionDuringPrompt(permissionParams("Bash", "rm -rf build")); + ask(panel, "hi"); + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> assertTrue(panel.isPermissionPopupVisibleForTesting())); + panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE, KeyModifiers.NONE)); + awaitIdle(panel); + assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-reject)")); + } + + @Test + void popupRendersWhileWaiting() throws Exception { + AiPanel panel = acpPanel(); + askPermissionDuringPrompt(permissionParams("Bash", "rm -rf build")); + ask(panel, "hi"); + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> assertTrue(panel.isPermissionPopupVisibleForTesting())); + Rect area = new Rect(0, 0, 100, 30); + Buffer buffer = Buffer.empty(area); + panel.render(Frame.forTesting(buffer), area); + assertTrue(TuiTestHelper.bufferToString(buffer).contains("rm -rf build")); + enter(panel); + awaitIdle(panel); + } + + @Test + void providerSwitchClosesTheAgent() throws Exception { + AiPanel panel = acpPanel(); + ask(panel, "hi"); + awaitIdle(panel); + panel.selectProviderForTesting("anthropic"); + assertFalse(panel.isAcpProviderForTesting()); + assertNull(panel.acpSessionIdForTesting()); + assertTrue(agent.awaitClientClosed(Duration.ofSeconds(5)), "the agent process is closed on a provider switch"); + } + + @Test + void missingExecutableShowsTheInstallHint() { + AiPanel panel = new AiPanel(); + panel.setMcpUrlSupplierForTestingOrRuntime(() -> "http://127.0.0.1:4242/mcp"); + TuiSettings settings = TuiSettings.load(); + settings.setAiAcpCommand("definitely-not-a-real-binary-42 --acp"); + settings.save(); + panel.open(); + panel.selectProviderForTesting("acp:custom"); + ask(panel, "hi"); + awaitIdle(panel); + assertTrue(hasEntry(panel, AiRole.ERROR, "definitely-not-a-real-binary-42 not found")); + } + + @Test + void ctrlCWhileThePopupWaitsCancelsTheTurn() throws Exception { + AiPanel panel = acpPanel(); + askPermissionDuringPrompt(permissionParams("Bash", "rm -rf build")); + ask(panel, "hi"); + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> assertTrue(panel.isPermissionPopupVisibleForTesting())); + panel.handleKeyEvent(KeyEvent.ofChar('c', KeyModifiers.CTRL)); + awaitIdle(panel); + assertFalse(panel.isPermissionPopupVisibleForTesting()); + assertTrue(hasEntry(panel, AiRole.SYSTEM, "(cancelled)")); + } + + @Test + void unknownSlashCommandIsForwardedToTheAgentWithoutThePreamble() throws Exception { + AiPanel panel = acpPanel(); + ask(panel, "/review src"); + awaitIdle(panel); + assertEquals("/review src", promptText(agent.received("session/prompt").get(0))); + ask(panel, "hello"); + awaitIdle(panel); + assertTrue(promptText(agent.received("session/prompt").get(1)).contains("Apache Camel assistant"), + "the first regular prompt still carries the preamble"); + } + + @Test + void panelCommandsStillWinOverTheAgent() throws Exception { + AiPanel panel = acpPanel(); + ask(panel, "/help"); + assertTrue(hasEntry(panel, AiRole.SYSTEM, "/provider")); + assertEquals(0, agent.receivedCount("session/prompt")); + } + + @Test + void headerStripAppearsOnceTheSessionIsOpen() throws Exception { + AiPanel panel = acpPanel(); + // wide enough that the meta line is never clipped by a deep checkout path + Rect area = new Rect(0, 0, 200, 20); + Buffer before = Buffer.empty(area); + panel.render(Frame.forTesting(before), area); + assertFalse(TuiTestHelper.bufferToString(before).contains("session "), "no header before the session opens"); + advertiseReviewCommand(); + ask(panel, "hi"); + awaitIdle(panel); + Buffer after = Buffer.empty(area); + panel.render(Frame.forTesting(after), area); + String rendered = TuiTestHelper.bufferToString(after); + assertTrue(rendered.contains("Claude Code (ACP) · fake-agent 1.2.3"), rendered); + assertTrue(rendered.contains("session " + panel.acpSessionIdForTesting()), rendered); + assertTrue(rendered.contains(AcpHeaderStrip.homeRelative(Path.of("").toAbsolutePath())), rendered); + assertTrue(rendered.contains("1 command"), rendered); + assertFalse(hasEntry(panel, AiRole.SYSTEM, " connected"), "no banner entry in the conversation any more"); + } + + @Test + void headerStripIsHiddenOnAShortPanel() throws Exception { + AiPanel panel = acpPanel(); + ask(panel, "hi"); + awaitIdle(panel); + Rect area = new Rect(0, 0, 120, 6); + Buffer buffer = Buffer.empty(area); + panel.render(Frame.forTesting(buffer), area); + assertFalse(TuiTestHelper.bufferToString(buffer).contains("session ")); + } + + @Test + void agentCommandsAppearInHintsAndTabCompletion() throws Exception { + AiPanel panel = acpPanel(); + advertiseReviewCommand(); + ask(panel, "hi"); + awaitIdle(panel); + type(panel, "/rev"); + Rect area = new Rect(0, 0, 100, 20); + Buffer buffer = Buffer.empty(area); + panel.render(Frame.forTesting(buffer), area); + String rendered = TuiTestHelper.bufferToString(buffer); + assertTrue(rendered.contains("/agent:review focus area")); + assertTrue(rendered.contains("Review the current changes")); + panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.TAB, KeyModifiers.NONE)); + assertEquals("/agent:review ", panel.inputBufferForTesting()); + } + + @Test + void agentPrefixForwardsEvenAPanelCommandName() throws Exception { + AiPanel panel = acpPanel(); + ask(panel, "/agent:clear"); + awaitIdle(panel); + assertEquals("/clear", promptText(agent.received("session/prompt").get(0))); + assertTrue(hasEntry(panel, AiRole.USER, "/agent:clear"), "the panel conversation was not cleared"); + } + + @Test + void agentPrefixAloneListsTheAgentCommands() throws Exception { + AiPanel panel = acpPanel(); + advertiseReviewCommand(); + ask(panel, "hi"); + awaitIdle(panel); + ask(panel, "/agent:"); + Rect area = new Rect(0, 0, 120, 20); + Buffer buffer = Buffer.empty(area); + panel.render(Frame.forTesting(buffer), area); + String listing = TuiTestHelper.bufferToString(buffer); + assertTrue(listing.lines().anyMatch( + line -> line.contains("/agent:review") && line.contains("Review the current changes")), + listing); + assertEquals(1, agent.receivedCount("session/prompt"), "nothing was sent to the agent"); + } + + @Test + void agentCommandsAreShownAndCompletedWithThePrefix() throws Exception { + AiPanel panel = acpPanel(); + advertiseReviewCommand(); + ask(panel, "hi"); + awaitIdle(panel); + type(panel, "/agent:rev"); + Rect area = new Rect(0, 0, 100, 20); + Buffer buffer = Buffer.empty(area); + panel.render(Frame.forTesting(buffer), area); + assertTrue(TuiTestHelper.bufferToString(buffer).contains("/agent:review")); + panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.TAB, KeyModifiers.NONE)); + assertEquals("/agent:review ", panel.inputBufferForTesting()); + } + + @Test + void retryResendsTheLastQuestionToTheAgent() throws Exception { + AiPanel panel = acpPanel(); + ask(panel, "/retry"); + assertTrue(hasEntry(panel, AiRole.ERROR, "No question to retry")); + ask(panel, "hello"); + awaitIdle(panel); + ask(panel, "/retry"); + awaitIdle(panel); + assertEquals(2, agent.receivedCount("session/prompt")); + assertEquals("hello", promptText(agent.received("session/prompt").get(1)), "the preamble is not repeated"); + assertEquals(2, panel.conversationForTesting().stream().filter(e -> e.role() == AiRole.USER).count()); + } + + @Test + void retryReplaysAnAgentCommandWithoutThePrefix() throws Exception { + AiPanel panel = acpPanel(); + ask(panel, "/agent:clear"); + awaitIdle(panel); + ask(panel, "/retry"); + awaitIdle(panel); + assertEquals("/clear", promptText(agent.received("session/prompt").get(1))); + } + + @Test + void contextDescribesTheAgentSession() throws Exception { + AiPanel panel = acpPanel(); + advertiseReviewCommand(); + ask(panel, "/context"); + assertTrue(hasEntry(panel, AiRole.SYSTEM, "not started yet")); + assertEquals(0, agent.receivedCount("session/prompt")); + ask(panel, "hello"); + awaitIdle(panel); + ask(panel, "/context"); + String text = lastEntry(panel, AiRole.SYSTEM); + assertTrue(text.contains("Session: sess-cmd"), text); + assertTrue(text.contains("MCP: http://127.0.0.1:4242/mcp"), text); + assertTrue(text.contains("Agent commands: 1"), text); + assertTrue(text.contains("Preamble: ~"), text); + assertTrue(text.contains("/compact and /tools do not apply"), text); + assertEquals(1, agent.receivedCount("session/prompt"), "/context is answered by the panel"); + } + + @Test + void compactAndToolsExplainThatTheAgentOwnsThem() throws Exception { + AiPanel panel = acpPanel(); + advertiseCommands("compact"); + ask(panel, "hello"); + awaitIdle(panel); + ask(panel, "/compact"); + assertTrue(hasEntry(panel, AiRole.SYSTEM, "manages its own conversation history")); + assertTrue(hasEntry(panel, AiRole.SYSTEM, "Use /agent:compact")); + ask(panel, "/tools"); + assertTrue(hasEntry(panel, AiRole.SYSTEM, "through the MCP server directly")); + ask(panel, "/tools core"); + assertTrue(hasEntry(panel, AiRole.ERROR, "only applies to LLM providers")); + assertNull(TuiSettings.load().getAiTools(), "the LLM tool set was not changed"); + assertEquals(1, agent.receivedCount("session/prompt"), "nothing was forwarded to the agent"); + } + + @Test + void compactWithoutAnAgentCommandGivesNoHint() { + AiPanel panel = new AiPanel(); + panel.open(); + panel.selectProviderForTesting("acp:codex"); + ask(panel, "/compact"); + assertTrue(hasEntry(panel, AiRole.SYSTEM, "Codex (ACP) manages its own conversation history")); + assertFalse(hasEntry(panel, AiRole.SYSTEM, "/agent:compact")); + } + + @Test + void promptShowsThePreambleSentToTheAgent() { + AiPanel panel = new AiPanel(); + panel.open(); + panel.selectProviderForTesting("acp:codex"); + ask(panel, "/prompt"); + assertTrue(hasEntry(panel, AiRole.SYSTEM, "Sent to Codex (ACP) ahead of the first prompt")); + assertTrue(hasEntry(panel, AiRole.SYSTEM, "Apache Camel assistant")); + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelectorTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelectorTest.java index b8f85286c6e5a..c621d51f8650b 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelectorTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelectorTest.java @@ -16,6 +16,7 @@ */ package org.apache.camel.dsl.jbang.core.commands.tui; +import java.nio.file.Files; import java.nio.file.Path; import java.util.List; @@ -23,12 +24,18 @@ import org.apache.camel.dsl.jbang.core.common.CommandLineHelper; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.parallel.Isolated; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -59,7 +66,8 @@ void buildChoicesAlwaysListsAllProvidersForManualSelection(@TempDir Path tempDir List choices = selector.buildChoices(); - assertEquals(List.of("auto", "anthropic", "openai", "gemini", "ollama", "watsonx"), + assertEquals(List.of("auto", "anthropic", "openai", "gemini", "ollama", "watsonx", + "acp:claude", "acp:codex", "acp:bob", "acp:qwen", "acp:opencode", "acp:dsh"), choices.stream().map(AiProviderSwitchPopup.ProviderChoice::provider).toList(), "all known providers must be offered, even without a detected API key, so they remain selectable"); assertTrue(choices.get(0).persistedDefault()); @@ -74,7 +82,8 @@ void defaultProviderIsNotDuplicated(@TempDir Path tempDir) { List choices = selector.buildChoices(); - assertEquals(List.of("anthropic", "openai", "gemini", "ollama", "watsonx"), + assertEquals(List.of("anthropic", "openai", "gemini", "ollama", "watsonx", + "acp:claude", "acp:codex", "acp:bob", "acp:qwen", "acp:opencode", "acp:dsh"), choices.stream().map(AiProviderSwitchPopup.ProviderChoice::provider).toList(), "anthropic must not be listed twice when it's already the default"); } @@ -88,7 +97,8 @@ void ollamaDefaultIsNotDuplicated(@TempDir Path tempDir) { List choices = selector.buildChoices(); - assertEquals(List.of("ollama", "anthropic", "openai", "gemini", "watsonx"), + assertEquals(List.of("ollama", "anthropic", "openai", "gemini", "watsonx", + "acp:claude", "acp:codex", "acp:bob", "acp:qwen", "acp:opencode", "acp:dsh"), choices.stream().map(AiProviderSwitchPopup.ProviderChoice::provider).toList()); } @@ -101,7 +111,8 @@ void watsonxDefaultIsNotDuplicated(@TempDir Path tempDir) { List choices = selector.buildChoices(); - assertEquals(List.of("watsonx", "anthropic", "openai", "gemini", "ollama"), + assertEquals(List.of("watsonx", "anthropic", "openai", "gemini", "ollama", + "acp:claude", "acp:codex", "acp:bob", "acp:qwen", "acp:opencode", "acp:dsh"), choices.stream().map(AiProviderSwitchPopup.ProviderChoice::provider).toList()); } @@ -150,4 +161,72 @@ void applyChoiceRejectsUnknownProviderWithActionableMessage() { .hasMessageContaining("anthropic") .hasMessageContaining("watsonx"); } + + @Test + void customAcpRowAppearsOnlyWhenCommandIsConfigured(@TempDir Path tempDir) { + useHome(tempDir); + assertFalse(selector.buildChoices().stream().anyMatch(c -> "acp:custom".equals(c.provider()))); + TuiSettings settings = TuiSettings.load(); + settings.setAiAcpCommand("npx -y pi-acp"); + settings.save(); + assertTrue(selector.buildChoices().stream().anyMatch(c -> "acp:custom".equals(c.provider()))); + } + + @Test + void acpDefaultIsListedFirstAndNotDuplicated(@TempDir Path tempDir) { + useHome(tempDir); + TuiSettings settings = TuiSettings.load(); + settings.setAiProvider("acp:codex"); + settings.save(); + List ids = selector.buildChoices().stream().map(AiProviderSwitchPopup.ProviderChoice::provider).toList(); + assertEquals("acp:codex", ids.get(0)); + assertEquals(1, ids.stream().filter("acp:codex"::equals).count()); + } + + @Test + void applyChoiceLeavesLlmClientUntouchedForAcpProviders() { + LlmClient client = LlmClient.create(); + LlmClient.ApiType before = client.apiType(); + selector.applyChoice(client, "acp:claude", "", ""); + assertEquals(before, client.apiType()); + } + + @Test + void acpPresetResolvesCommandsAndCustomCommand(@TempDir Path tempDir) { + useHome(tempDir); + TuiSettings settings = TuiSettings.load(); + AiProviderSelector.AcpPreset claude = selector.acpPreset("acp:claude", settings); + assertEquals(List.of("npx", "-y", "@agentclientprotocol/claude-agent-acp"), claude.command()); + assertEquals("npx", claude.executable()); + assertEquals("Claude Code (ACP)", AiProviderSelector.acpLabel("acp:claude")); + assertThrows(IllegalArgumentException.class, () -> selector.acpPreset("acp:custom", settings)); + settings.setAiAcpCommand(" /opt/agent/bin/agent --acp "); + AiProviderSelector.AcpPreset custom = selector.acpPreset("acp:custom", settings); + assertEquals(List.of("/opt/agent/bin/agent", "--acp"), custom.command()); + assertEquals("/opt/agent/bin/agent", custom.executable()); + assertEquals("Custom (ACP)", custom.label()); + assertEquals("✱", claude.glyph()); + assertEquals("●", custom.glyph()); + assertThrows(IllegalArgumentException.class, () -> selector.acpPreset("acp:nope", settings)); + } + + @Test + @EnabledOnOs({ OS.LINUX, OS.MAC }) + void resolveExecutableFindsShellButNotNonsense() { + assertNotNull(AiProviderSelector.resolveExecutable("sh")); + assertNull(AiProviderSelector.resolveExecutable("definitely-not-a-real-binary-42")); + } + + @Test + void resolveExecutableReturnsTheFileItFoundIncludingACmdShim(@TempDir Path tempDir) throws Exception { + Path bob = Files.createFile(tempDir.resolve("fakebob")); + assertTrue(bob.toFile().setExecutable(true)); + Path npx = Files.createFile(tempDir.resolve("fakenpx.cmd")); + assertTrue(npx.toFile().setExecutable(true)); + String path = tempDir.toString(); + assertEquals(bob.toString(), AiProviderSelector.resolveExecutable("fakebob", path)); + assertEquals(npx.toString(), AiProviderSelector.resolveExecutable("fakenpx", path)); + assertEquals(npx.toString(), AiProviderSelector.resolveExecutable(tempDir.resolve("fakenpx").toString(), path)); + assertNull(AiProviderSelector.resolveExecutable("definitely-not-a-real-binary-42", path)); + } } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSwitchPopupTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSwitchPopupTest.java index d558f73fb33d2..fa0c2de113eb8 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSwitchPopupTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSwitchPopupTest.java @@ -50,6 +50,14 @@ void enterSelectsHighlightedChoice() { assertFalse(popup.isVisible()); } + @Test + void acpChoiceIsLabelledWithTheAgentName() { + assertEquals("Claude Code (ACP) default", + new AiProviderSwitchPopup.ProviderChoice("acp:claude", "", "", true).label()); + assertEquals("Claude Code (ACP)", + new AiProviderSwitchPopup.ProviderChoice("acp:claude", "", "", false).label()); + } + @Test void escapeCancelsWithoutSelection() { AiProviderSwitchPopup popup = new AiProviderSwitchPopup(); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistryTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistryTest.java index c386d8bb38a46..667c0fc1c22f7 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistryTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistryTest.java @@ -94,6 +94,39 @@ void completionsHideAfterKnownCommandWithTrailingSpace() { assertTrue(registry.completionsFor("/send ").isEmpty()); } + @Test + void completionsIncludeExtraDescriptorsButRegistryCommandsWin() { + AiSlashCommandRegistry registry = AiSlashCommandRegistry.defaults(); + List extra = List.of( + new AiSlashCommandRegistry.Descriptor("review", List.of(), "Review changes", "focus", null), + new AiSlashCommandRegistry.Descriptor("help", List.of(), "Agent help", null, null)); + List all = registry.completionsFor("/", extra).stream().map(AiSlashCommandRegistry.Descriptor::name).toList(); + assertTrue(all.contains("review")); + assertEquals(1, all.stream().filter("help"::equals).count(), "the panel's /help wins over the agent's"); + assertEquals(List.of("review"), + registry.completionsFor("/rev", extra).stream().map(AiSlashCommandRegistry.Descriptor::name).toList()); + assertTrue(registry.completionsFor("/review ", extra).isEmpty(), + "a complete command followed by a space hides the hints"); + assertTrue(registry.completionsFor("/review focus", extra).isEmpty()); + } + + @Test + void prefixedAgentDescriptorsMatchByAliasAndHideWhenComplete() { + AiSlashCommandRegistry registry = AiSlashCommandRegistry.defaults(); + List extra = List.of( + new AiSlashCommandRegistry.Descriptor("agent:review", List.of("review"), "Review changes", "focus", null), + new AiSlashCommandRegistry.Descriptor("agent:clear", List.of("clear"), "Clear the agent context", null, null)); + assertEquals(List.of("agent:review"), + registry.completionsFor("/rev", extra).stream().map(AiSlashCommandRegistry.Descriptor::name).toList()); + assertEquals(List.of("agent:review", "agent:clear"), + registry.completionsFor("/agent:", extra).stream().map(AiSlashCommandRegistry.Descriptor::name).toList()); + assertTrue(registry.completionsFor("/agent:review ", extra).isEmpty()); + assertTrue(registry.completionsFor("/review ", extra).isEmpty(), + "a complete alias followed by a space hides the hints too"); + assertTrue(registry.completionsFor("/clear", extra).stream().anyMatch(d -> "agent:clear".equals(d.name())), + "the agent's clear is offered next to the panel's because its display name does not collide"); + } + @Test void placeholderUsesRegistryDescriptor() { AiSlashCommandRegistry registry = AiSlashCommandRegistry.defaults(); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FakeAcpAgent.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FakeAcpAgent.java new file mode 100644 index 0000000000000..82286c937cbdc --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FakeAcpAgent.java @@ -0,0 +1,267 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Function; + +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; + +import static org.awaitility.Awaitility.await; + +/** + * In-memory ACP agent for tests. Talks newline-delimited JSON-RPC over pipes. Request handlers run on their own thread + * so a handler may itself send notifications and agent-initiated requests (e.g. permission prompts) and wait for the + * client's answers. + */ +final class FakeAcpAgent implements AutoCloseable { + + private final PipedOutputStream agentOut = new PipedOutputStream(); + private final PipedOutputStream clientOut = new PipedOutputStream(); + private final PipedInputStream clientIn; + private final PipedInputStream agentIn; + private final BufferedReader reader; + private final Writer writer; + private final Map> handlers = new ConcurrentHashMap<>(); + private final Map errors = new ConcurrentHashMap<>(); + private final List received = new CopyOnWriteArrayList<>(); + private final Map> pendingAgentRequests = new ConcurrentHashMap<>(); + private final AtomicLong ids = new AtomicLong(1000); + private final CountDownLatch clientClosed = new CountDownLatch(1); + private final ExecutorService handlerExecutor = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "fake-acp-handler"); + t.setDaemon(true); + return t; + }); + + FakeAcpAgent() throws IOException { + clientIn = new PipedInputStream(agentOut, 1 << 16); + agentIn = new PipedInputStream(clientOut, 1 << 16); + reader = new BufferedReader(new InputStreamReader(agentIn, StandardCharsets.UTF_8)); + writer = new BufferedWriter(new OutputStreamWriter(agentOut, StandardCharsets.UTF_8)); + onRequest("initialize", params -> defaultInitializeResult()); + onRequest("session/new", params -> { + JsonObject r = new JsonObject(); + r.put("sessionId", "sess-" + ids.incrementAndGet()); + return r; + }); + onRequest("session/prompt", params -> { + JsonObject r = new JsonObject(); + r.put("stopReason", "end_turn"); + return r; + }); + onRequest("authenticate", params -> new JsonObject()); + Thread t = new Thread(this::loop, "fake-acp-reader"); + t.setDaemon(true); + t.start(); + } + + static JsonObject defaultInitializeResult() { + JsonObject mcp = new JsonObject(); + mcp.put("http", true); + mcp.put("sse", false); + JsonObject caps = new JsonObject(); + caps.put("mcpCapabilities", mcp); + JsonObject info = new JsonObject(); + info.put("name", "fake-agent"); + info.put("version", "1.2.3"); + JsonObject r = new JsonObject(); + r.put("protocolVersion", 1); + r.put("agentCapabilities", caps); + r.put("authMethods", new JsonArray()); + r.put("agentInfo", info); + return r; + } + + InputStream clientInput() { + return clientIn; + } + + OutputStream clientOutput() { + return clientOut; + } + + void onRequest(String method, Function resultBuilder) { + errors.remove(method); + handlers.put(method, resultBuilder); + } + + /** The next request for {@code method} is answered with this JSON-RPC error once, then the handler applies. */ + void failRequest(String method, int code, String message) { + JsonObject error = new JsonObject(); + error.put("code", code); + error.put("message", message); + errors.put(method, error); + } + + void sendNotification(String method, JsonObject params) { + JsonObject msg = new JsonObject(); + msg.put("jsonrpc", "2.0"); + msg.put("method", method); + msg.put("params", params); + write(msg); + } + + /** Agent-initiated request; blocks up to 5 seconds for the client's response and returns it. */ + JsonObject sendRequest(String method, JsonObject params) { + long id = ids.incrementAndGet(); + CompletableFuture future = new CompletableFuture<>(); + pendingAgentRequests.put(id, future); + JsonObject msg = new JsonObject(); + msg.put("jsonrpc", "2.0"); + msg.put("id", id); + msg.put("method", method); + msg.put("params", params); + write(msg); + try { + return future.get(5, TimeUnit.SECONDS); + } catch (Exception e) { + throw new IllegalStateException("No response to " + method, e); + } + } + + void sendRaw(String line) { + synchronized (writer) { + try { + writer.write(line); + writer.write('\n'); + writer.flush(); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + } + + JsonObject awaitReceived(String method, Duration timeout) { + await("client never sent " + method).atMost(timeout).until(() -> find(method) != null); + return find(method); + } + + /** True when the client closed its end of the pipe within {@code timeout}. */ + boolean awaitClientClosed(Duration timeout) throws InterruptedException { + return clientClosed.await(timeout.toMillis(), TimeUnit.MILLISECONDS); + } + + private JsonObject find(String method) { + return received.stream().filter(m -> method.equals(m.getString("method"))).findFirst().orElse(null); + } + + long receivedCount(String method) { + return received.stream().filter(m -> method.equals(m.getString("method"))).count(); + } + + /** Every message the client sent with this method, in arrival order. */ + List received(String method) { + return received.stream().filter(m -> method.equals(m.getString("method"))).toList(); + } + + /** Closes the agent side of both pipes: the client sees EOF, like a crashed process. */ + @Override + public void close() { + try { + agentOut.close(); + } catch (IOException ignored) { + } + try { + agentIn.close(); + } catch (IOException ignored) { + } + handlerExecutor.shutdownNow(); + } + + private void loop() { + try { + String line; + while ((line = reader.readLine()) != null) { + JsonObject msg = Jsoner.deserialize(line, (JsonObject) null); + if (msg == null) { + continue; + } + received.add(msg); + String method = msg.getString("method"); + Object id = msg.get("id"); + if (method != null && id != null) { + if (handlerExecutor.isShutdown()) { + continue; // closed while a line was still buffered + } + handlerExecutor.execute(() -> respond(id, method, msg.getJsonObject("params"))); + } else if (method == null && id != null) { + CompletableFuture f = pendingAgentRequests.remove(((Number) id).longValue()); + if (f != null) { + f.complete(msg); + } + } + } + } catch (IOException ignored) { + // pipe closed + } finally { + clientClosed.countDown(); + } + } + + private void respond(Object id, String method, JsonObject params) { + JsonObject response = new JsonObject(); + response.put("jsonrpc", "2.0"); + response.put("id", id); + JsonObject error = errors.remove(method); + if (error != null) { + response.put("error", error); + } else { + Function handler = handlers.get(method); + if (handler == null) { + JsonObject notFound = new JsonObject(); + notFound.put("code", -32601); + notFound.put("message", "Method not found: " + method); + response.put("error", notFound); + } else { + response.put("result", handler.apply(params != null ? params : new JsonObject())); + } + } + try { + write(response); + } catch (IllegalStateException e) { + // the client closed the pipe mid-flight; nothing to answer + } + } + + private void write(JsonObject msg) { + sendRaw(Jsoner.serialize(msg)); + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopupRenderTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopupRenderTest.java index 92730c0e98ab7..9386952c9c36d 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopupRenderTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopupRenderTest.java @@ -22,6 +22,9 @@ import dev.tamboui.buffer.Buffer; import dev.tamboui.layout.Rect; import dev.tamboui.terminal.Frame; +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.tui.event.KeyModifiers; import org.apache.camel.dsl.jbang.core.common.CommandLineHelper; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -29,6 +32,8 @@ import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.parallel.Isolated; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -67,7 +72,7 @@ void rendersTitleAndAllSettingRows(@TempDir Path tempDir) { new TabRegistry.TabEntry("🩺", "Health", "health", "7", 6, -1))); popup.open(); - Rect area = new Rect(0, 0, 80, 25); + Rect area = new Rect(0, 0, 80, 28); Buffer buffer = Buffer.empty(area); Frame frame = Frame.forTesting(buffer); popup.render(frame, area); @@ -84,5 +89,37 @@ void rendersTitleAndAllSettingRows(@TempDir Path tempDir) { assertTrue(rendered.contains("AI Base URL"), "the AI Base URL row should be shown"); assertTrue(rendered.contains("Shell History"), "the Shell History row should be shown"); assertTrue(rendered.contains("AI History"), "the AI History row should be shown"); + assertTrue(rendered.contains("ACP Command"), "the ACP Command row should be shown"); + } + + @Test + void lineOfAccountsForEveryRowAndDivider() { + assertEquals(SettingsPopup.ROW_COUNT + SettingsPopup.DIVIDERS, + SettingsPopup.lineOf(SettingsPopup.ROW_COUNT - 1) + 1); + } + + @Test + void scrollsTheSelectedRowIntoViewOnAShortTerminal(@TempDir Path tempDir) { + useHome(tempDir); + SettingsPopup popup = new SettingsPopup(); + popup.setTabEntries(List.of( + new TabRegistry.TabEntry("🐪", "Overview", "overview", "1", 0, -1), + new TabRegistry.TabEntry("🩺", "Health", "health", "7", 6, -1))); + popup.open(); + Rect area = new Rect(0, 0, 80, 25); + Buffer buffer = Buffer.empty(area); + popup.render(Frame.forTesting(buffer), area); + String rendered = TuiTestHelper.bufferToString(buffer); + assertTrue(rendered.contains("Theme:"), rendered); + assertFalse(rendered.contains("ACP Command"), "the last rows do not fit in 25 lines"); + for (int i = 0; i < 18; i++) { + popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.NONE)); + } + buffer = Buffer.empty(area); + popup.render(Frame.forTesting(buffer), area); + rendered = TuiTestHelper.bufferToString(buffer); + assertTrue(rendered.contains("ACP Command"), rendered); + assertTrue(rendered.contains("AI History"), rendered); + assertFalse(rendered.contains("Theme:"), "the first rows scrolled out"); } } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopupTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopupTest.java index 7dc071e4d1c45..c27c09d76d91d 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopupTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopupTest.java @@ -394,6 +394,15 @@ void panelRowsPersistPositionAndSpace(@TempDir Path tempDir) { assertTrue(persisted.isPanelOverlay()); } + @Test + void aiProviderDropdownOffersAcpAgents() { + SettingsPopup popup = new SettingsPopup(); + popup.open(); + assertTrue(popup.aiProviderOptionsForTesting().contains("acp:claude")); + assertTrue(popup.aiProviderOptionsForTesting().contains("acp:custom")); + assertEquals("auto", popup.aiProviderOptionsForTesting().get(popup.aiProviderOptionsForTesting().size() - 1)); + } + @Test void panelRowsDefaultValuesAreNotPersisted(@TempDir Path tempDir) { useHome(tempDir); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServerPortTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServerPortTest.java new file mode 100644 index 0000000000000..00990ae8643ad --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServerPortTest.java @@ -0,0 +1,35 @@ +/* + * 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 org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TuiMcpServerPortTest { + + @Test + void ephemeralPortIsReportedAfterStart() throws Exception { + TuiMcpServer server = new TuiMcpServer(0, null); + try { + server.start(); + assertTrue(server.getPort() > 0, "port 0 must be replaced by the bound ephemeral port"); + } finally { + server.stop(); + } + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiSettingsTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiSettingsTest.java index 00ba4ad323fe4..55f3442046edb 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiSettingsTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiSettingsTest.java @@ -64,6 +64,7 @@ void roundTripPreservesAllFields(@TempDir Path tempDir) { settings.setAiModel("gemini-3.5-flash"); settings.setAiUrl("https://generativelanguage.googleapis.com"); settings.setAiTools("core"); + settings.setAiAcpCommand("npx -y pi-acp"); settings.setShellHistory("25"); settings.setAiPromptHistory("50"); settings.setPanelPosition("top"); @@ -78,6 +79,7 @@ void roundTripPreservesAllFields(@TempDir Path tempDir) { assertThat(loaded.getAiModel()).isEqualTo("gemini-3.5-flash"); assertThat(loaded.getAiUrl()).isEqualTo("https://generativelanguage.googleapis.com"); assertThat(loaded.getAiTools()).isEqualTo("core"); + assertThat(loaded.getAiAcpCommand()).isEqualTo("npx -y pi-acp"); assertThat(loaded.getShellHistory()).isEqualTo("25"); assertThat(loaded.getAiPromptHistory()).isEqualTo("50"); assertThat(loaded.getPanelPosition()).isEqualTo("top"); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistryCoreToolsTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistryCoreToolsTest.java index b38d749ce48fe..ec79299aa6820 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistryCoreToolsTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistryCoreToolsTest.java @@ -58,4 +58,23 @@ void coreSetLeavesOutScreenAutomationTools() { assertFalse(TuiToolRegistry.CORE_TOOLS.stream().anyMatch(automation::contains)); } + + @Test + void readOnlyToolsAreRegisteredAndCoverEveryGetter() { + TuiToolRegistry registry = new TuiToolRegistry(null); + Set all = registry.getToolDefinitions().stream() + .map(TuiToolRegistry.ToolDef::name).collect(Collectors.toSet()); + + assertTrue(all.containsAll(TuiToolRegistry.READ_ONLY_TOOLS), + "read-only tools missing from registry: " + TuiToolRegistry.READ_ONLY_TOOLS.stream() + .filter(name -> !all.contains(name)).toList()); + List getters = all.stream().filter(name -> name.startsWith("tui_get_")).sorted().toList(); + assertTrue(TuiToolRegistry.READ_ONLY_TOOLS.containsAll(getters), + "a new getter must be classified deliberately: " + getters.stream() + .filter(name -> !TuiToolRegistry.READ_ONLY_TOOLS.contains(name)).toList()); + for (String mutating : List.of("tui_control", "tui_execute_sql", "tui_send_message", "tui_write_file", + "tui_infra")) { + assertFalse(TuiToolRegistry.READ_ONLY_TOOLS.contains(mutating), mutating + " changes something"); + } + } }