From c7c9f7bc2f0e140ab7716ff395523ca09a49f27f Mon Sep 17 00:00:00 2001 From: Stephan Janssen Date: Fri, 13 Feb 2026 08:39:05 +0100 Subject: [PATCH 01/10] feat: add ACP protocol layer for agent communication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement JSON-RPC 2.0 over stdin/stdout transport adapted from the ACP-Prototype project. Provides structured streaming, file operations, terminal management, and capability negotiation for external ACP agents. - AcpTransport: process-based transport with async request/response matching - AcpClient: high-level client (initialize → createSession → sendPrompt) - AgentRequestHandler: handles fs/read, fs/write, terminal, and permission requests - JsonRpcMessage: JSON-RPC 2.0 message model with serialization support - Model POJOs for protocol handshake and session management Co-Authored-By: Claude Opus 4.6 --- .../service/acp/model/ClientCapabilities.java | 25 +++ .../genie/service/acp/model/ClientInfo.java | 13 ++ .../genie/service/acp/model/ContentBlock.java | 8 + .../service/acp/model/InitializeParams.java | 15 ++ .../service/acp/model/InitializeResult.java | 5 + .../service/acp/model/SessionNewParams.java | 15 ++ .../service/acp/model/SessionNewResult.java | 5 + .../acp/model/SessionPromptParams.java | 18 ++ .../acp/model/SessionUpdateParams.java | 8 + .../genie/service/acp/protocol/AcpClient.java | 101 ++++++++++ .../acp/protocol/AcpRequestException.java | 8 + .../service/acp/protocol/AcpTransport.java | 188 ++++++++++++++++++ .../acp/protocol/AgentRequestHandler.java | 166 ++++++++++++++++ .../service/acp/protocol/JsonRpcMessage.java | 65 ++++++ 14 files changed, 640 insertions(+) create mode 100644 src/main/java/com/devoxx/genie/service/acp/model/ClientCapabilities.java create mode 100644 src/main/java/com/devoxx/genie/service/acp/model/ClientInfo.java create mode 100644 src/main/java/com/devoxx/genie/service/acp/model/ContentBlock.java create mode 100644 src/main/java/com/devoxx/genie/service/acp/model/InitializeParams.java create mode 100644 src/main/java/com/devoxx/genie/service/acp/model/InitializeResult.java create mode 100644 src/main/java/com/devoxx/genie/service/acp/model/SessionNewParams.java create mode 100644 src/main/java/com/devoxx/genie/service/acp/model/SessionNewResult.java create mode 100644 src/main/java/com/devoxx/genie/service/acp/model/SessionPromptParams.java create mode 100644 src/main/java/com/devoxx/genie/service/acp/model/SessionUpdateParams.java create mode 100644 src/main/java/com/devoxx/genie/service/acp/protocol/AcpClient.java create mode 100644 src/main/java/com/devoxx/genie/service/acp/protocol/AcpRequestException.java create mode 100644 src/main/java/com/devoxx/genie/service/acp/protocol/AcpTransport.java create mode 100644 src/main/java/com/devoxx/genie/service/acp/protocol/AgentRequestHandler.java create mode 100644 src/main/java/com/devoxx/genie/service/acp/protocol/JsonRpcMessage.java diff --git a/src/main/java/com/devoxx/genie/service/acp/model/ClientCapabilities.java b/src/main/java/com/devoxx/genie/service/acp/model/ClientCapabilities.java new file mode 100644 index 00000000..a1dea219 --- /dev/null +++ b/src/main/java/com/devoxx/genie/service/acp/model/ClientCapabilities.java @@ -0,0 +1,25 @@ +package com.devoxx.genie.service.acp.model; + +public class ClientCapabilities { + public FileSystemCapability fs; + public Boolean terminal; + + public static ClientCapabilities full() { + ClientCapabilities caps = new ClientCapabilities(); + caps.fs = new FileSystemCapability(true, true); + caps.terminal = true; + return caps; + } + + public static class FileSystemCapability { + public boolean readTextFile; + public boolean writeTextFile; + + public FileSystemCapability() {} + + public FileSystemCapability(boolean readTextFile, boolean writeTextFile) { + this.readTextFile = readTextFile; + this.writeTextFile = writeTextFile; + } + } +} diff --git a/src/main/java/com/devoxx/genie/service/acp/model/ClientInfo.java b/src/main/java/com/devoxx/genie/service/acp/model/ClientInfo.java new file mode 100644 index 00000000..42a8dd79 --- /dev/null +++ b/src/main/java/com/devoxx/genie/service/acp/model/ClientInfo.java @@ -0,0 +1,13 @@ +package com.devoxx.genie.service.acp.model; + +public class ClientInfo { + public String name; + public String version; + + public ClientInfo() {} + + public ClientInfo(String name, String version) { + this.name = name; + this.version = version; + } +} diff --git a/src/main/java/com/devoxx/genie/service/acp/model/ContentBlock.java b/src/main/java/com/devoxx/genie/service/acp/model/ContentBlock.java new file mode 100644 index 00000000..5272d02a --- /dev/null +++ b/src/main/java/com/devoxx/genie/service/acp/model/ContentBlock.java @@ -0,0 +1,8 @@ +package com.devoxx.genie.service.acp.model; + +public class ContentBlock { + public String type; + public String text; + + public ContentBlock() {} +} diff --git a/src/main/java/com/devoxx/genie/service/acp/model/InitializeParams.java b/src/main/java/com/devoxx/genie/service/acp/model/InitializeParams.java new file mode 100644 index 00000000..9d7bc7f6 --- /dev/null +++ b/src/main/java/com/devoxx/genie/service/acp/model/InitializeParams.java @@ -0,0 +1,15 @@ +package com.devoxx.genie.service.acp.model; + +public class InitializeParams { + public int protocolVersion; + public ClientCapabilities clientCapabilities; + public ClientInfo clientInfo; + + public InitializeParams() {} + + public InitializeParams(int protocolVersion, ClientCapabilities clientCapabilities, ClientInfo clientInfo) { + this.protocolVersion = protocolVersion; + this.clientCapabilities = clientCapabilities; + this.clientInfo = clientInfo; + } +} diff --git a/src/main/java/com/devoxx/genie/service/acp/model/InitializeResult.java b/src/main/java/com/devoxx/genie/service/acp/model/InitializeResult.java new file mode 100644 index 00000000..4e1fb26d --- /dev/null +++ b/src/main/java/com/devoxx/genie/service/acp/model/InitializeResult.java @@ -0,0 +1,5 @@ +package com.devoxx.genie.service.acp.model; + +public class InitializeResult { + public String protocolVersion; +} diff --git a/src/main/java/com/devoxx/genie/service/acp/model/SessionNewParams.java b/src/main/java/com/devoxx/genie/service/acp/model/SessionNewParams.java new file mode 100644 index 00000000..4d8d7aaf --- /dev/null +++ b/src/main/java/com/devoxx/genie/service/acp/model/SessionNewParams.java @@ -0,0 +1,15 @@ +package com.devoxx.genie.service.acp.model; + +import java.util.List; + +public class SessionNewParams { + public String cwd; + public List mcpServers; + + public SessionNewParams() {} + + public SessionNewParams(String cwd) { + this.cwd = cwd; + this.mcpServers = List.of(); + } +} diff --git a/src/main/java/com/devoxx/genie/service/acp/model/SessionNewResult.java b/src/main/java/com/devoxx/genie/service/acp/model/SessionNewResult.java new file mode 100644 index 00000000..87c23202 --- /dev/null +++ b/src/main/java/com/devoxx/genie/service/acp/model/SessionNewResult.java @@ -0,0 +1,5 @@ +package com.devoxx.genie.service.acp.model; + +public class SessionNewResult { + public String sessionId; +} diff --git a/src/main/java/com/devoxx/genie/service/acp/model/SessionPromptParams.java b/src/main/java/com/devoxx/genie/service/acp/model/SessionPromptParams.java new file mode 100644 index 00000000..fd738f49 --- /dev/null +++ b/src/main/java/com/devoxx/genie/service/acp/model/SessionPromptParams.java @@ -0,0 +1,18 @@ +package com.devoxx.genie.service.acp.model; + +import java.util.List; + +public class SessionPromptParams { + public String sessionId; + public List prompt; + + public SessionPromptParams() {} + + public SessionPromptParams(String sessionId, String text) { + this.sessionId = sessionId; + ContentBlock block = new ContentBlock(); + block.type = "text"; + block.text = text; + this.prompt = List.of(block); + } +} diff --git a/src/main/java/com/devoxx/genie/service/acp/model/SessionUpdateParams.java b/src/main/java/com/devoxx/genie/service/acp/model/SessionUpdateParams.java new file mode 100644 index 00000000..424847db --- /dev/null +++ b/src/main/java/com/devoxx/genie/service/acp/model/SessionUpdateParams.java @@ -0,0 +1,8 @@ +package com.devoxx.genie.service.acp.model; + +import com.fasterxml.jackson.databind.JsonNode; + +public class SessionUpdateParams { + public String sessionId; + public JsonNode update; +} diff --git a/src/main/java/com/devoxx/genie/service/acp/protocol/AcpClient.java b/src/main/java/com/devoxx/genie/service/acp/protocol/AcpClient.java new file mode 100644 index 00000000..52d8c1be --- /dev/null +++ b/src/main/java/com/devoxx/genie/service/acp/protocol/AcpClient.java @@ -0,0 +1,101 @@ +package com.devoxx.genie.service.acp.protocol; + +import com.devoxx.genie.service.acp.model.*; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; + +import java.io.File; +import java.util.function.Consumer; + +@Slf4j +public class AcpClient implements AutoCloseable { + + private final AcpTransport transport; + private final Consumer outputConsumer; + private String sessionId; + + public AcpClient(Consumer outputConsumer) { + this.transport = new AcpTransport(); + AgentRequestHandler requestHandler = new AgentRequestHandler(transport); + this.outputConsumer = outputConsumer; + + transport.setNotificationHandler(this::handleNotification); + transport.setRequestHandler(requestHandler::handle); + } + + public void start(File cwd, String... command) throws Exception { + transport.start(cwd, command); + } + + public void initialize() throws Exception { + InitializeParams params = new InitializeParams( + 1, + ClientCapabilities.full(), + new ClientInfo("DevoxxGenie", "1.0.0") + ); + + JsonRpcMessage response = transport.sendRequest("initialize", params); + if (response.error != null) { + throw new RuntimeException("Initialize failed: " + response.error.message); + } + + InitializeResult result = AcpTransport.MAPPER.treeToValue(response.result, InitializeResult.class); + log.info("[ACP] Connected to agent, protocol version: {}", result.protocolVersion); + } + + public void createSession(String cwd) throws Exception { + SessionNewParams params = new SessionNewParams(cwd); + + JsonRpcMessage response = transport.sendRequest("session/new", params); + if (response.error != null) { + throw new RuntimeException("session/new failed: " + response.error.message); + } + + SessionNewResult result = AcpTransport.MAPPER.treeToValue(response.result, SessionNewResult.class); + this.sessionId = result.sessionId; + log.info("[ACP] Session created: {}", sessionId); + } + + public void sendPrompt(String text) throws Exception { + if (sessionId == null) { + throw new IllegalStateException("No active session. Call createSession() first."); + } + + SessionPromptParams params = new SessionPromptParams(sessionId, text); + + JsonRpcMessage response = transport.sendRequest("session/prompt", params); + if (response.error != null) { + throw new RuntimeException("session/prompt failed: " + response.error.message); + } + } + + private void handleNotification(JsonRpcMessage msg) { + if ("session/update".equals(msg.method)) { + try { + SessionUpdateParams updateParams = AcpTransport.MAPPER.treeToValue(msg.params, SessionUpdateParams.class); + if (updateParams.update == null) return; + + String updateType = updateParams.update.has("sessionUpdate") + ? updateParams.update.get("sessionUpdate").asText() : ""; + + // Print text from agent message chunks only (skip thought chunks) + if ("agent_message_chunk".equals(updateType)) { + JsonNode content = updateParams.update.get("content"); + if (content != null) { + ContentBlock block = AcpTransport.MAPPER.treeToValue(content, ContentBlock.class); + if ("text".equals(block.type) && block.text != null) { + outputConsumer.accept(block.text); + } + } + } + } catch (Exception e) { + log.warn("[ACP] Failed to parse session/update: {}", e.getMessage(), e); + } + } + } + + @Override + public void close() { + transport.close(); + } +} diff --git a/src/main/java/com/devoxx/genie/service/acp/protocol/AcpRequestException.java b/src/main/java/com/devoxx/genie/service/acp/protocol/AcpRequestException.java new file mode 100644 index 00000000..71ffd7cd --- /dev/null +++ b/src/main/java/com/devoxx/genie/service/acp/protocol/AcpRequestException.java @@ -0,0 +1,8 @@ +package com.devoxx.genie.service.acp.protocol; + +public class AcpRequestException extends Exception { + + public AcpRequestException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/devoxx/genie/service/acp/protocol/AcpTransport.java b/src/main/java/com/devoxx/genie/service/acp/protocol/AcpTransport.java new file mode 100644 index 00000000..ec8d3f87 --- /dev/null +++ b/src/main/java/com/devoxx/genie/service/acp/protocol/AcpTransport.java @@ -0,0 +1,188 @@ +package com.devoxx.genie.service.acp.protocol; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.intellij.util.EnvironmentUtil; +import lombok.extern.slf4j.Slf4j; + +import java.io.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +@Slf4j +public class AcpTransport implements AutoCloseable { + + public static final ObjectMapper MAPPER = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); + + private Process process; + private BufferedWriter writer; + private final AtomicInteger idCounter = new AtomicInteger(1); + private final ConcurrentHashMap> pendingRequests = new ConcurrentHashMap<>(); + + private Consumer notificationHandler; + private Consumer requestHandler; + private volatile boolean running = true; + + public void setNotificationHandler(Consumer handler) { + this.notificationHandler = handler; + } + + public void setRequestHandler(Consumer handler) { + this.requestHandler = handler; + } + + public void start(File cwd, String... command) throws IOException { + ProcessBuilder pb = new ProcessBuilder(command); + pb.redirectError(ProcessBuilder.Redirect.INHERIT); + if (cwd != null) { + pb.directory(cwd); + } + pb.environment().putAll(EnvironmentUtil.getEnvironmentMap()); + process = pb.start(); + + writer = new BufferedWriter(new OutputStreamWriter(process.getOutputStream())); + + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + Thread readerThread = new Thread(() -> readLoop(reader), "acp-reader"); + readerThread.setDaemon(true); + readerThread.start(); + } + + private void readLoop(BufferedReader reader) { + try { + String line; + while (running && (line = reader.readLine()) != null) { + handleLine(line); + } + } catch (IOException e) { + handleReadError(e); + } + } + + private void handleLine(String line) { + String trimmed = line.trim(); + if (trimmed.isEmpty()) { + return; + } + + JsonRpcMessage msg = parseMessage(trimmed); + if (msg == null) { + return; + } + + dispatchMessage(msg); + } + + private JsonRpcMessage parseMessage(String line) { + try { + return MAPPER.readValue(line, JsonRpcMessage.class); + } catch (Exception e) { + log.warn("[ACP] Failed to parse: {}", line); + return null; + } + } + + private void dispatchMessage(JsonRpcMessage msg) { + if (msg.isResponse()) { + completePendingRequest(msg); + return; + } + + if (msg.isNotification()) { + handleNotification(msg); + return; + } + + if (msg.isRequest() && requestHandler != null) { + requestHandler.accept(msg); + } + } + + private void completePendingRequest(JsonRpcMessage msg) { + CompletableFuture future = pendingRequests.remove(msg.id); + if (future != null) { + future.complete(msg); + } + } + + private void handleNotification(JsonRpcMessage msg) { + if (notificationHandler != null) { + notificationHandler.accept(msg); + } + } + + private void handleReadError(IOException e) { + if (running) { + log.warn("[ACP] Reader error: {}", e.getMessage()); + } + } + + public JsonRpcMessage sendRequest(String method, Object params) + throws IOException, InterruptedException, TimeoutException, AcpRequestException { + int id = idCounter.getAndIncrement(); + JsonRpcMessage msg = JsonRpcMessage.request(id, method, params); + CompletableFuture future = new CompletableFuture<>(); + pendingRequests.put(id, future); + + try { + sendRaw(msg); + } catch (IOException e) { + pendingRequests.remove(id); + throw e; + } + + try { + return future.get(120, TimeUnit.SECONDS); + } catch (InterruptedException e) { + pendingRequests.remove(id); + Thread.currentThread().interrupt(); + throw e; + } catch (TimeoutException e) { + pendingRequests.remove(id); + throw e; + } catch (ExecutionException e) { + pendingRequests.remove(id); + Throwable cause = e.getCause() != null ? e.getCause() : e; + throw new AcpRequestException("Request failed for method: " + method, cause); + } + } + + public void sendResponse(int id, Object result) throws IOException { + sendRaw(JsonRpcMessage.response(id, result)); + } + + public void sendErrorResponse(int id, int code, String message) throws IOException { + sendRaw(JsonRpcMessage.errorResponse(id, code, message)); + } + + private synchronized void sendRaw(JsonRpcMessage msg) throws IOException { + String json = MAPPER.writeValueAsString(msg); + writer.write(json); + writer.newLine(); + writer.flush(); + } + + @Override + public void close() { + running = false; + pendingRequests.forEach((id, future) -> future.cancel(true)); + pendingRequests.clear(); + if (process != null) { + process.destroy(); + try { + process.waitFor(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + process.destroyForcibly(); + } + } + } +} diff --git a/src/main/java/com/devoxx/genie/service/acp/protocol/AgentRequestHandler.java b/src/main/java/com/devoxx/genie/service/acp/protocol/AgentRequestHandler.java new file mode 100644 index 00000000..3acca484 --- /dev/null +++ b/src/main/java/com/devoxx/genie/service/acp/protocol/AgentRequestHandler.java @@ -0,0 +1,166 @@ +package com.devoxx.genie.service.acp.protocol; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +@Slf4j +public class AgentRequestHandler { + + private final AcpTransport transport; + private final ConcurrentHashMap terminals = new ConcurrentHashMap<>(); + + public AgentRequestHandler(AcpTransport transport) { + this.transport = transport; + } + + public void handle(JsonRpcMessage msg) { + try { + Object result = dispatch(msg.method, msg.params); + transport.sendResponse(msg.id, result); + } catch (Exception e) { + try { + transport.sendErrorResponse(msg.id, -32603, e.getMessage()); + } catch (IOException ex) { + log.error("[ACP] Failed to send error response: {}", ex.getMessage(), ex); + } + } + } + + private Object dispatch(String method, JsonNode params) throws Exception { + return switch (method) { + case "fs/read_text_file" -> handleFsRead(params); + case "fs/write_text_file" -> handleFsWrite(params); + case "session/request_permission" -> handleRequestPermission(params); + case "terminal/create" -> handleTerminalCreate(params); + case "terminal/output" -> handleTerminalOutput(params); + case "terminal/wait_for_exit" -> handleTerminalWaitForExit(params); + case "terminal/release" -> handleTerminalRelease(params); + case "terminal/kill" -> handleTerminalKill(params); + default -> throw new UnsupportedOperationException("Unknown method: " + method); + }; + } + + private Object handleFsRead(JsonNode params) throws IOException { + String path = params.get("path").asText(); + String content = Files.readString(Path.of(path)); + return Map.of("content", content); + } + + private Object handleFsWrite(JsonNode params) throws IOException { + String path = params.get("path").asText(); + String content = params.get("content").asText(); + Path filePath = Path.of(path); + Files.createDirectories(filePath.getParent()); + Files.writeString(filePath, content); + return Map.of("success", true); + } + + private Object handleRequestPermission(JsonNode params) { + // Auto-approve all permissions + return Map.of("granted", true); + } + + private Object handleTerminalCreate(JsonNode params) throws IOException { + String terminalId = params.has("terminalId") ? params.get("terminalId").asText() + : "term-" + System.currentTimeMillis(); + String command = params.get("command").asText(); + String cwd = params.has("cwd") ? params.get("cwd").asText() : System.getProperty("user.dir"); + + ProcessBuilder pb = new ProcessBuilder("sh", "-c", command); + pb.directory(new File(cwd)); + pb.redirectErrorStream(true); + + Process proc = pb.start(); + ManagedProcess mp = new ManagedProcess(proc, terminalId); + terminals.put(terminalId, mp); + + Thread captureThread = new Thread(() -> captureOutput(mp), "term-" + terminalId); + captureThread.setDaemon(true); + captureThread.start(); + + return Map.of("terminalId", terminalId); + } + + private void captureOutput(ManagedProcess mp) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(mp.process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + mp.appendOutput(line + "\n"); + } + } catch (IOException e) { + // Process ended + } + } + + private Object handleTerminalOutput(JsonNode params) { + String terminalId = params.get("terminalId").asText(); + ManagedProcess mp = terminals.get(terminalId); + if (mp == null) { + return Map.of("output", "", "error", "Unknown terminal: " + terminalId); + } + String output = mp.consumeOutput(); + return Map.of("output", output); + } + + private Object handleTerminalWaitForExit(JsonNode params) throws InterruptedException { + String terminalId = params.get("terminalId").asText(); + int timeoutMs = params.has("timeout") ? params.get("timeout").asInt() : 30000; + ManagedProcess mp = terminals.get(terminalId); + if (mp == null) { + return Map.of("exitCode", -1, "error", "Unknown terminal: " + terminalId); + } + boolean finished = mp.process.waitFor(timeoutMs, TimeUnit.MILLISECONDS); + if (finished) { + int exitCode = mp.process.exitValue(); + return Map.of("exitCode", exitCode, "output", mp.consumeOutput()); + } else { + return Map.of("exitCode", -1, "timedOut", true, "output", mp.consumeOutput()); + } + } + + private Object handleTerminalRelease(JsonNode params) { + String terminalId = params.get("terminalId").asText(); + ManagedProcess mp = terminals.remove(terminalId); + if (mp != null && mp.process.isAlive()) { + mp.process.destroy(); + } + return Map.of("success", true); + } + + private Object handleTerminalKill(JsonNode params) { + String terminalId = params.get("terminalId").asText(); + ManagedProcess mp = terminals.remove(terminalId); + if (mp != null) { + mp.process.destroyForcibly(); + } + return Map.of("success", true); + } + + private static class ManagedProcess { + final Process process; + final String terminalId; + private final StringBuilder outputBuffer = new StringBuilder(); + + ManagedProcess(Process process, String terminalId) { + this.process = process; + this.terminalId = terminalId; + } + + synchronized void appendOutput(String text) { + outputBuffer.append(text); + } + + synchronized String consumeOutput() { + String out = outputBuffer.toString(); + outputBuffer.setLength(0); + return out; + } + } +} diff --git a/src/main/java/com/devoxx/genie/service/acp/protocol/JsonRpcMessage.java b/src/main/java/com/devoxx/genie/service/acp/protocol/JsonRpcMessage.java new file mode 100644 index 00000000..1131afcb --- /dev/null +++ b/src/main/java/com/devoxx/genie/service/acp/protocol/JsonRpcMessage.java @@ -0,0 +1,65 @@ +package com.devoxx.genie.service.acp.protocol; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class JsonRpcMessage { + public String jsonrpc = "2.0"; + public Integer id; + public String method; + public JsonNode params; + public JsonNode result; + public JsonRpcError error; + + public JsonRpcMessage() {} + + public boolean isRequest() { + return method != null && id != null; + } + + public boolean isNotification() { + return method != null && id == null; + } + + public boolean isResponse() { + return method == null && id != null; + } + + public static JsonRpcMessage request(int id, String method, Object params) { + JsonRpcMessage msg = new JsonRpcMessage(); + msg.id = id; + msg.method = method; + if (params != null) { + msg.params = AcpTransport.MAPPER.valueToTree(params); + } + return msg; + } + + public static JsonRpcMessage response(int id, Object resultObj) { + JsonRpcMessage msg = new JsonRpcMessage(); + msg.id = id; + msg.result = AcpTransport.MAPPER.valueToTree(resultObj); + return msg; + } + + public static JsonRpcMessage errorResponse(int id, int code, String message) { + JsonRpcMessage msg = new JsonRpcMessage(); + msg.id = id; + msg.error = new JsonRpcError(code, message); + return msg; + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class JsonRpcError { + public int code; + public String message; + + public JsonRpcError() {} + + public JsonRpcError(int code, String message) { + this.code = code; + this.message = message; + } + } +} From eaa4450425f171d3c896a1b5818fabb36791e0d0 Mon Sep 17 00:00:00 2001 From: Stephan Janssen Date: Fri, 13 Feb 2026 08:39:17 +0100 Subject: [PATCH 02/10] feat: register ACP Runners as a new provider Add ACPRunners enum value and wire it through the full provider pipeline: factory registration, state service persistence, LLM provider visibility, model dropdown filtering, and Langchain4J bypass for chat context creation. Co-Authored-By: Claude Opus 4.6 --- .../chatmodel/ChatModelFactoryProvider.java | 2 + .../AcpRunnersChatModelFactory.java | 41 +++++++++++++++++++ .../ActionButtonsPanelController.java | 10 +++++ .../model/enumarations/ModelProvider.java | 1 + .../genie/model/spec/AcpToolConfig.java | 39 ++++++++++++++++++ .../genie/service/LLMProviderService.java | 11 +++++ .../genie/ui/panel/LlmProviderPanel.java | 1 + .../ui/settings/DevoxxGenieStateService.java | 3 ++ .../agent/AgentSettingsComponent.java | 2 + .../genie/util/ChatMessageContextUtil.java | 5 ++- 10 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/devoxx/genie/chatmodel/local/acprunners/AcpRunnersChatModelFactory.java create mode 100644 src/main/java/com/devoxx/genie/model/spec/AcpToolConfig.java diff --git a/src/main/java/com/devoxx/genie/chatmodel/ChatModelFactoryProvider.java b/src/main/java/com/devoxx/genie/chatmodel/ChatModelFactoryProvider.java index f3463447..8df17cb7 100644 --- a/src/main/java/com/devoxx/genie/chatmodel/ChatModelFactoryProvider.java +++ b/src/main/java/com/devoxx/genie/chatmodel/ChatModelFactoryProvider.java @@ -16,6 +16,7 @@ import com.devoxx.genie.chatmodel.local.customopenai.CustomOpenAIChatModelFactory; import com.devoxx.genie.chatmodel.local.gpt4all.GPT4AllChatModelFactory; import com.devoxx.genie.chatmodel.local.jan.JanChatModelFactory; +import com.devoxx.genie.chatmodel.local.acprunners.AcpRunnersChatModelFactory; import com.devoxx.genie.chatmodel.local.clirunners.CliRunnersChatModelFactory; import com.devoxx.genie.chatmodel.local.llamacpp.LlamaChatModelFactory; import com.devoxx.genie.chatmodel.local.lmstudio.LMStudioChatModelFactory; @@ -67,6 +68,7 @@ private ChatModelFactoryProvider() { case "Kimi" -> new KimiChatModelFactory(); case "GLM" -> new GLMChatModelFactory(); case "CLI Runners" -> new CliRunnersChatModelFactory(); + case "ACP Runners" -> new AcpRunnersChatModelFactory(); default -> null; }; } diff --git a/src/main/java/com/devoxx/genie/chatmodel/local/acprunners/AcpRunnersChatModelFactory.java b/src/main/java/com/devoxx/genie/chatmodel/local/acprunners/AcpRunnersChatModelFactory.java new file mode 100644 index 00000000..49001691 --- /dev/null +++ b/src/main/java/com/devoxx/genie/chatmodel/local/acprunners/AcpRunnersChatModelFactory.java @@ -0,0 +1,41 @@ +package com.devoxx.genie.chatmodel.local.acprunners; + +import com.devoxx.genie.chatmodel.ChatModelFactory; +import com.devoxx.genie.model.CustomChatModel; +import com.devoxx.genie.model.LanguageModel; +import com.devoxx.genie.model.enumarations.ModelProvider; +import com.devoxx.genie.model.spec.AcpToolConfig; +import com.devoxx.genie.ui.settings.DevoxxGenieStateService; +import dev.langchain4j.model.chat.ChatModel; + +import java.util.List; + +/** + * ChatModelFactory for ACP Runners provider. + * Returns enabled ACP tools as LanguageModel entries. + * ACP Runners bypass Langchain4J entirely — createChatModel/createStreamingChatModel return null. + */ +public class AcpRunnersChatModelFactory implements ChatModelFactory { + + @Override + public ChatModel createChatModel(CustomChatModel customChatModel) { + return null; + } + + @Override + public List getModels() { + return DevoxxGenieStateService.getInstance().getAcpTools().stream() + .filter(AcpToolConfig::isEnabled) + .map(tool -> LanguageModel.builder() + .provider(ModelProvider.ACPRunners) + .modelName(tool.getName()) + .displayName(tool.getName()) + .apiKeyUsed(false) + .inputCost(0) + .outputCost(0) + .inputMaxTokens(0) + .outputMaxTokens(0) + .build()) + .toList(); + } +} diff --git a/src/main/java/com/devoxx/genie/controller/ActionButtonsPanelController.java b/src/main/java/com/devoxx/genie/controller/ActionButtonsPanelController.java index 3b5c8b46..df46005e 100644 --- a/src/main/java/com/devoxx/genie/controller/ActionButtonsPanelController.java +++ b/src/main/java/com/devoxx/genie/controller/ActionButtonsPanelController.java @@ -151,6 +151,16 @@ private LanguageModel createDefaultLanguageModel(@NotNull DevoxxGenieSettingsSer .outputCost(0) .inputMaxTokens(0) .build(); + } else if (selectedProvider != null && selectedProvider.equals(ACPRunners)) { + return LanguageModel.builder() + .provider(ACPRunners) + .modelName("") + .displayName("ACP Runner") + .apiKeyUsed(false) + .inputCost(0) + .outputCost(0) + .inputMaxTokens(0) + .build(); } else if (selectedProvider != null && (selectedProvider.equals(LMStudio) || selectedProvider.equals(GPT4All) || diff --git a/src/main/java/com/devoxx/genie/model/enumarations/ModelProvider.java b/src/main/java/com/devoxx/genie/model/enumarations/ModelProvider.java index 8f42d9ba..20ccb6ea 100644 --- a/src/main/java/com/devoxx/genie/model/enumarations/ModelProvider.java +++ b/src/main/java/com/devoxx/genie/model/enumarations/ModelProvider.java @@ -15,6 +15,7 @@ public enum ModelProvider { LMStudio("LMStudio", Type.LOCAL), Ollama("Ollama", Type.LOCAL), CLIRunners("CLI Runners", Type.LOCAL), + ACPRunners("ACP Runners", Type.LOCAL), OpenAI("OpenAI", Type.CLOUD), Anthropic("Anthropic", Type.CLOUD), diff --git a/src/main/java/com/devoxx/genie/model/spec/AcpToolConfig.java b/src/main/java/com/devoxx/genie/model/spec/AcpToolConfig.java new file mode 100644 index 00000000..ae71ea98 --- /dev/null +++ b/src/main/java/com/devoxx/genie/model/spec/AcpToolConfig.java @@ -0,0 +1,39 @@ +package com.devoxx.genie.model.spec; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AcpToolConfig { + + @Getter + public enum AcpType { + KIMI("Kimi", "kimi"), + GEMINI("Gemini", "gemini"), + KILOCODE("Kilocode", "kilocode"), + CUSTOM("Custom", ""); + + private final String displayName; + private final String defaultExecutablePath; + + AcpType(String displayName, String defaultExecutablePath) { + this.displayName = displayName; + this.defaultExecutablePath = defaultExecutablePath; + } + } + + @Builder.Default + private AcpType type = AcpType.CUSTOM; + @Builder.Default + private String name = ""; + @Builder.Default + private String executablePath = ""; + @Builder.Default + private boolean enabled = true; +} diff --git a/src/main/java/com/devoxx/genie/service/LLMProviderService.java b/src/main/java/com/devoxx/genie/service/LLMProviderService.java index d47c6ab9..f5f41bc3 100644 --- a/src/main/java/com/devoxx/genie/service/LLMProviderService.java +++ b/src/main/java/com/devoxx/genie/service/LLMProviderService.java @@ -2,6 +2,7 @@ import com.devoxx.genie.model.LanguageModel; import com.devoxx.genie.model.enumarations.ModelProvider; +import com.devoxx.genie.model.spec.AcpToolConfig; import com.devoxx.genie.model.spec.CliToolConfig; import com.devoxx.genie.service.models.LLMModelRegistryService; import com.devoxx.genie.ui.settings.DevoxxGenieStateService; @@ -48,6 +49,7 @@ public List getAvailableModelProviders() { providers.addAll(getLocalModelProviders()); providers.addAll(getOptionalProviders()); providers.addAll(getCliRunnersProvider()); + providers.addAll(getAcpRunnersProvider()); return providers; } @@ -89,6 +91,15 @@ private List getModelProvidersWithApiKeyConfigured() { return optionalModelProviders; } + private @NotNull List getAcpRunnersProvider() { + boolean hasEnabledAcpTool = DevoxxGenieStateService.getInstance().getAcpTools().stream() + .anyMatch(AcpToolConfig::isEnabled); + if (hasEnabledAcpTool) { + return List.of(ModelProvider.ACPRunners); + } + return List.of(); + } + private @NotNull List getCliRunnersProvider() { boolean hasEnabledCliTool = DevoxxGenieStateService.getInstance().getCliTools().stream() .anyMatch(CliToolConfig::isEnabled); diff --git a/src/main/java/com/devoxx/genie/ui/panel/LlmProviderPanel.java b/src/main/java/com/devoxx/genie/ui/panel/LlmProviderPanel.java index 816e5382..3122d42f 100644 --- a/src/main/java/com/devoxx/genie/ui/panel/LlmProviderPanel.java +++ b/src/main/java/com/devoxx/genie/ui/panel/LlmProviderPanel.java @@ -141,6 +141,7 @@ public void addModelProvidersToComboBox() { case AzureOpenAI -> stateService.isAzureOpenAIEnabled(); case Bedrock -> stateService.isAwsEnabled(); case CLIRunners -> true; + case ACPRunners -> true; }) .distinct() .sorted(Comparator.comparing(ModelProvider::getName)) diff --git a/src/main/java/com/devoxx/genie/ui/settings/DevoxxGenieStateService.java b/src/main/java/com/devoxx/genie/ui/settings/DevoxxGenieStateService.java index b94c77e0..44fab7dd 100644 --- a/src/main/java/com/devoxx/genie/ui/settings/DevoxxGenieStateService.java +++ b/src/main/java/com/devoxx/genie/ui/settings/DevoxxGenieStateService.java @@ -244,6 +244,9 @@ public static DevoxxGenieStateService getInstance() { private String specRunnerMode = "llm"; // "llm" or "cli" private String specSelectedCliTool = ""; // name of selected CLI tool + // ACP tool runner settings + private List acpTools = new ArrayList<>(); + // Inline completion settings private String inlineCompletionProvider = ""; // "", "Ollama", or "LMStudio" private String inlineCompletionModel = ""; diff --git a/src/main/java/com/devoxx/genie/ui/settings/agent/AgentSettingsComponent.java b/src/main/java/com/devoxx/genie/ui/settings/agent/AgentSettingsComponent.java index 097113c7..cea7bd13 100644 --- a/src/main/java/com/devoxx/genie/ui/settings/agent/AgentSettingsComponent.java +++ b/src/main/java/com/devoxx/genie/ui/settings/agent/AgentSettingsComponent.java @@ -275,6 +275,7 @@ private void populateProviderComboBox() { case AzureOpenAI -> state.isAzureOpenAIEnabled(); case Bedrock -> state.isAwsEnabled(); case CLIRunners -> false; + case ACPRunners -> false; }) .distinct() .sorted(Comparator.comparing(ModelProvider::getName)) @@ -440,6 +441,7 @@ private void populateRowProviderComboBox(ComboBox comboBox) { case AzureOpenAI -> state.isAzureOpenAIEnabled(); case Bedrock -> state.isAwsEnabled(); case CLIRunners -> false; + case ACPRunners -> false; }) .distinct() .sorted(Comparator.comparing(ModelProvider::getName)) diff --git a/src/main/java/com/devoxx/genie/util/ChatMessageContextUtil.java b/src/main/java/com/devoxx/genie/util/ChatMessageContextUtil.java index 165ce962..a466d1a9 100644 --- a/src/main/java/com/devoxx/genie/util/ChatMessageContextUtil.java +++ b/src/main/java/com/devoxx/genie/util/ChatMessageContextUtil.java @@ -46,8 +46,9 @@ private ChatMessageContextUtil() { .cost(0) .build(); - // CLI Runners bypass Langchain4J — no chat model needed - if (chatContextParameters.languageModel().getProvider() != ModelProvider.CLIRunners) { + // CLI Runners and ACP Runners bypass Langchain4J — no chat model needed + if (chatContextParameters.languageModel().getProvider() != ModelProvider.CLIRunners && + chatContextParameters.languageModel().getProvider() != ModelProvider.ACPRunners) { if (Boolean.TRUE.equals(stateService.getStreamMode())) { chatMessageContext.setStreamingChatModel(chatContextParameters.chatModelProvider().getStreamingChatLanguageModel(chatMessageContext)); } else { From cfdc3ea73e88e95a30dcd43ca809fbbdfb9879bd Mon Sep 17 00:00:00 2001 From: Stephan Janssen Date: Fri, 13 Feb 2026 08:39:27 +0100 Subject: [PATCH 03/10] feat: add ACP prompt execution strategy and settings UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AcpPromptStrategy runs the full ACP flow (init → session → prompt) on a pooled thread, streaming agent_message_chunk notifications to the WebView. Settings UI adds an ACP Runners section with table, add/edit/remove dialog, and a test button that performs an ACP initialize handshake. Co-Authored-By: Claude Opus 4.6 --- .../prompt/strategy/AcpPromptStrategy.java | 176 ++++++++++ .../PromptExecutionStrategyFactory.java | 7 + .../settings/spec/SpecSettingsComponent.java | 309 +++++++++++++++++- 3 files changed, 491 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/devoxx/genie/service/prompt/strategy/AcpPromptStrategy.java diff --git a/src/main/java/com/devoxx/genie/service/prompt/strategy/AcpPromptStrategy.java b/src/main/java/com/devoxx/genie/service/prompt/strategy/AcpPromptStrategy.java new file mode 100644 index 00000000..7b096c6e --- /dev/null +++ b/src/main/java/com/devoxx/genie/service/prompt/strategy/AcpPromptStrategy.java @@ -0,0 +1,176 @@ +package com.devoxx.genie.service.prompt.strategy; + +import com.devoxx.genie.model.request.ChatMessageContext; +import com.devoxx.genie.model.spec.AcpToolConfig; +import com.devoxx.genie.service.acp.protocol.AcpClient; +import com.devoxx.genie.service.cli.CliConsoleManager; +import com.devoxx.genie.service.prompt.memory.ChatMemoryManager; +import com.devoxx.genie.service.prompt.result.PromptResult; +import com.devoxx.genie.service.prompt.threading.PromptTask; +import com.devoxx.genie.ui.panel.PromptOutputPanel; +import com.devoxx.genie.ui.settings.DevoxxGenieStateService; +import com.devoxx.genie.ui.webview.ConversationWebViewController; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.project.Project; +import dev.langchain4j.data.message.AiMessage; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.File; + +/** + * Prompt execution strategy for ACP Runners. + * Uses the ACP (Agent Communication Protocol) to communicate with external agents + * via JSON-RPC 2.0 over stdin/stdout, providing structured streaming, file operations, + * terminal management, and capability negotiation. + */ +@Slf4j +public class AcpPromptStrategy extends AbstractPromptExecutionStrategy { + + private volatile AcpClient activeClient; + + public AcpPromptStrategy(@NotNull Project project) { + super(project); + } + + @Override + protected String getStrategyName() { + return "ACP Runner"; + } + + @Override + protected void executeStrategySpecific(@NotNull ChatMessageContext context, + @NotNull PromptOutputPanel panel, + @NotNull PromptTask resultTask) { + String toolName = context.getLanguageModel().getModelName(); + AcpToolConfig acpTool = findAcpTool(toolName); + if (acpTool == null) { + log.error("ACP tool not found: {}", toolName); + resultTask.complete(PromptResult.failure(context, + new IllegalStateException("ACP tool not found: " + toolName))); + return; + } + + String prompt = context.getUserPrompt(); + String executablePath = acpTool.getExecutablePath(); + + log.info("ACP execute: tool={}, executable={}", toolName, executablePath); + + CliConsoleManager consoleManager = CliConsoleManager.getInstance(project); + consoleManager.printTaskHeader("acp-chat", prompt.length() > 60 ? prompt.substring(0, 60) + "..." : prompt, toolName); + consoleManager.activateToolWindow(); + + ConversationWebViewController webViewController = + panel.getConversationPanel() != null ? panel.getConversationPanel().webViewController : null; + + threadPoolManager.getPromptExecutionPool().execute(() -> + runAcpSession(context, acpTool, prompt, consoleManager, webViewController, resultTask)); + + resultTask.whenComplete((result, error) -> { + if (resultTask.isCancelled()) { + closeClient(); + } + }); + } + + private void runAcpSession(@NotNull ChatMessageContext context, + @NotNull AcpToolConfig acpTool, + @NotNull String prompt, + @NotNull CliConsoleManager consoleManager, + @Nullable ConversationWebViewController webViewController, + @NotNull PromptTask resultTask) { + long startTime = System.currentTimeMillis(); + StringBuilder accumulatedResponse = new StringBuilder(); + + try { + AcpClient client = new AcpClient(textChunk -> { + accumulatedResponse.append(textChunk); + final String fullText = accumulatedResponse.toString(); + + ApplicationManager.getApplication().invokeLater(() -> { + consoleManager.printOutput(textChunk); + + if (webViewController != null && !fullText.isEmpty()) { + context.setAiMessage(AiMessage.from(fullText)); + webViewController.updateAiMessageContent(context); + } + }); + }); + activeClient = client; + + String basePath = project.getBasePath(); + File cwd = basePath != null ? new File(basePath) : null; + + consoleManager.printSystem("[ACP] Starting " + acpTool.getName() + "..."); + client.start(cwd, acpTool.getExecutablePath(), "acp"); + + consoleManager.printSystem("[ACP] Initializing protocol..."); + client.initialize(); + + consoleManager.printSystem("[ACP] Creating session..."); + client.createSession(basePath != null ? basePath : System.getProperty("user.dir")); + + consoleManager.printSystem("[ACP] Sending prompt..."); + client.sendPrompt(prompt); + + activeClient = null; + client.close(); + + long elapsed = System.currentTimeMillis() - startTime; + log.info("ACP session completed: elapsed={}ms, responseLength={}", elapsed, accumulatedResponse.length()); + + ApplicationManager.getApplication().invokeLater(() -> { + String exitMsg = "\n=== ACP session completed (after " + elapsed + "ms) ===\n"; + consoleManager.printSystem(exitMsg); + + context.setExecutionTimeMs(elapsed); + String finalText = accumulatedResponse.toString(); + if (!finalText.isEmpty()) { + context.setAiMessage(AiMessage.from(finalText)); + } + + if (webViewController != null) { + webViewController.updateAiMessageContent(context); + webViewController.markMCPLogsAsCompleted(context.getId()); + } + + ChatMemoryManager.getInstance().addAiResponse(context); + resultTask.complete(PromptResult.success(context)); + }); + + } catch (Exception e) { + activeClient = null; + log.error("ACP session failed: {}", e.getMessage(), e); + ApplicationManager.getApplication().invokeLater(() -> { + consoleManager.printError("[ACP] Error: " + e.getMessage()); + resultTask.complete(PromptResult.failure(context, e)); + }); + } + } + + @Override + public void cancel() { + log.info("Cancelling ACP Runner strategy"); + closeClient(); + } + + private void closeClient() { + AcpClient client = activeClient; + if (client != null) { + log.info("Closing ACP client"); + client.close(); + activeClient = null; + ApplicationManager.getApplication().invokeLater(() -> + CliConsoleManager.getInstance(project).printSystem("\n=== ACP session cancelled ===\n")); + } + } + + private @Nullable AcpToolConfig findAcpTool(String toolName) { + return DevoxxGenieStateService.getInstance().getAcpTools().stream() + .filter(AcpToolConfig::isEnabled) + .filter(tool -> tool.getName().equals(toolName)) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/com/devoxx/genie/service/prompt/strategy/PromptExecutionStrategyFactory.java b/src/main/java/com/devoxx/genie/service/prompt/strategy/PromptExecutionStrategyFactory.java index eab6e67f..52c32232 100644 --- a/src/main/java/com/devoxx/genie/service/prompt/strategy/PromptExecutionStrategyFactory.java +++ b/src/main/java/com/devoxx/genie/service/prompt/strategy/PromptExecutionStrategyFactory.java @@ -27,6 +27,13 @@ public static PromptExecutionStrategyFactory getInstance() { public PromptExecutionStrategy createStrategy(@NotNull ChatMessageContext chatMessageContext) { Project project = chatMessageContext.getProject(); + // Check if ACP Runners provider — bypasses Langchain4J entirely + if (chatMessageContext.getLanguageModel() != null && + chatMessageContext.getLanguageModel().getProvider() == ModelProvider.ACPRunners) { + log.debug("Creating AcpPromptStrategy"); + return new AcpPromptStrategy(project); + } + // Check if CLI Runners provider — bypasses Langchain4J entirely if (chatMessageContext.getLanguageModel() != null && chatMessageContext.getLanguageModel().getProvider() == ModelProvider.CLIRunners) { diff --git a/src/main/java/com/devoxx/genie/ui/settings/spec/SpecSettingsComponent.java b/src/main/java/com/devoxx/genie/ui/settings/spec/SpecSettingsComponent.java index 8e00dee4..66a21cb9 100644 --- a/src/main/java/com/devoxx/genie/ui/settings/spec/SpecSettingsComponent.java +++ b/src/main/java/com/devoxx/genie/ui/settings/spec/SpecSettingsComponent.java @@ -1,5 +1,6 @@ package com.devoxx.genie.ui.settings.spec; +import com.devoxx.genie.model.spec.AcpToolConfig; import com.devoxx.genie.model.spec.CliToolConfig; import com.devoxx.genie.service.spec.BacklogConfigService; import com.devoxx.genie.service.spec.SpecService; @@ -50,6 +51,9 @@ public class SpecSettingsComponent extends AbstractSettingsComponent { private final CliToolTableModel cliToolTableModel = new CliToolTableModel(); private final JBTable cliToolTable = new JBTable(cliToolTableModel); + private final AcpToolTableModel acpToolTableModel = new AcpToolTableModel(); + private final JBTable acpToolTable = new JBTable(acpToolTableModel); + public SpecSettingsComponent(@NotNull Project project) { this.project = project; JPanel contentPanel = new JPanel(new GridBagLayout()); @@ -151,6 +155,40 @@ public SpecSettingsComponent(@NotNull Project project) { // Load existing CLI tools from state loadCliTools(); + // --- ACP Runners --- + addSection(contentPanel, gbc, "ACP Runners"); + + addHelpText(contentPanel, gbc, + "Configure external ACP (Agent Communication Protocol) tools that communicate " + + "via JSON-RPC 2.0 over stdin/stdout. ACP provides structured streaming, file operations, " + + "terminal management, and capability negotiation. " + + "Supported CLIs: Kimi, Gemini CLI, Kilocode."); + + // Configure ACP table columns + acpToolTable.getColumnModel().getColumn(0).setMaxWidth(60); // Enabled + acpToolTable.getColumnModel().getColumn(0).setMinWidth(60); + acpToolTable.getColumnModel().getColumn(1).setPreferredWidth(100); // Type + acpToolTable.getColumnModel().getColumn(2).setPreferredWidth(270); // Path + acpToolTable.setRowHeight(25); + + ToolbarDecorator acpDecorator = ToolbarDecorator.createDecorator(acpToolTable) + .setAddAction(button -> addAcpTool()) + .setEditAction(button -> editAcpTool()) + .setRemoveAction(button -> removeAcpTool()); + + JPanel acpTablePanel = acpDecorator.createPanel(); + acpTablePanel.setPreferredSize(new Dimension(-1, 150)); + + gbc.gridwidth = 2; + gbc.gridx = 0; + gbc.fill = GridBagConstraints.BOTH; + contentPanel.add(acpTablePanel, gbc); + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.gridy++; + + // Load existing ACP tools from state + loadAcpTools(); + // Filler gbc.weighty = 1.0; gbc.gridy++; @@ -210,6 +248,41 @@ private void removeCliTool() { } } + // ===== ACP Tool Table Management ===== + + private void loadAcpTools() { + List tools = stateService.getAcpTools(); + if (tools != null) { + for (AcpToolConfig tool : tools) { + acpToolTableModel.addTool(tool); + } + } + } + + private void addAcpTool() { + AcpToolDialog dialog = new AcpToolDialog(null); + if (dialog.showAndGet()) { + acpToolTableModel.addTool(dialog.getResult()); + } + } + + private void editAcpTool() { + int row = acpToolTable.getSelectedRow(); + if (row < 0) return; + AcpToolConfig existing = acpToolTableModel.getToolAt(row); + AcpToolDialog dialog = new AcpToolDialog(existing); + if (dialog.showAndGet()) { + acpToolTableModel.updateTool(row, dialog.getResult()); + } + } + + private void removeAcpTool() { + int row = acpToolTable.getSelectedRow(); + if (row >= 0) { + acpToolTableModel.removeTool(row); + } + } + // ===== Existing Helper Methods ===== private void addFullWidthRow(JPanel panel, GridBagConstraints gbc, JComponent component) { @@ -334,7 +407,8 @@ public boolean isModified() { return enableSpecBrowserCheckbox.isSelected() != Boolean.TRUE.equals(state.getSpecBrowserEnabled()) || !Objects.equals(specDirectoryField.getText().trim(), state.getSpecDirectory()) || !Objects.equals(taskRunnerTimeoutSpinner.getValue(), state.getSpecTaskRunnerTimeoutMinutes()) - || isCliToolsModified(); + || isCliToolsModified() + || isAcpToolsModified(); } private boolean isCliToolsModified() { @@ -344,11 +418,19 @@ private boolean isCliToolsModified() { return !saved.equals(current); } + private boolean isAcpToolsModified() { + List saved = stateService.getAcpTools(); + List current = acpToolTableModel.getAllTools(); + if (saved == null) return !current.isEmpty(); + return !saved.equals(current); + } + public void apply() { stateService.setSpecBrowserEnabled(enableSpecBrowserCheckbox.isSelected()); stateService.setSpecDirectory(specDirectoryField.getText().trim()); stateService.setSpecTaskRunnerTimeoutMinutes((Integer) taskRunnerTimeoutSpinner.getValue()); stateService.setCliTools(new ArrayList<>(cliToolTableModel.getAllTools())); + stateService.setAcpTools(new ArrayList<>(acpToolTableModel.getAllTools())); } public void reset() { @@ -364,6 +446,14 @@ public void reset() { cliToolTableModel.addTool(tool); } } + + acpToolTableModel.clear(); + List acpTools = state.getAcpTools(); + if (acpTools != null) { + for (AcpToolConfig tool : acpTools) { + acpToolTableModel.addTool(tool); + } + } } @Override @@ -761,4 +851,221 @@ public CliToolConfig getResult() { .build(); } } + + // ===== ACP Tool Table Model ===== + + private static class AcpToolTableModel extends AbstractTableModel { + private static final String[] COLUMNS = {"Enabled", "Type", "Executable Path"}; + private final List tools = new ArrayList<>(); + + @Override + public int getRowCount() { + return tools.size(); + } + + @Override + public int getColumnCount() { + return COLUMNS.length; + } + + @Override + public String getColumnName(int column) { + return COLUMNS[column]; + } + + @Override + public Class getColumnClass(int columnIndex) { + return columnIndex == 0 ? Boolean.class : String.class; + } + + @Override + public boolean isCellEditable(int rowIndex, int columnIndex) { + return columnIndex == 0; + } + + @Override + public Object getValueAt(int rowIndex, int columnIndex) { + AcpToolConfig tool = tools.get(rowIndex); + return switch (columnIndex) { + case 0 -> tool.isEnabled(); + case 1 -> tool.getType() != null ? tool.getType().getDisplayName() : ""; + case 2 -> tool.getExecutablePath(); + default -> ""; + }; + } + + @Override + public void setValueAt(Object value, int rowIndex, int columnIndex) { + if (columnIndex == 0 && value instanceof Boolean) { + tools.get(rowIndex).setEnabled((Boolean) value); + fireTableCellUpdated(rowIndex, columnIndex); + } + } + + public void addTool(AcpToolConfig tool) { + tools.add(tool); + fireTableRowsInserted(tools.size() - 1, tools.size() - 1); + } + + public void updateTool(int row, AcpToolConfig tool) { + tools.set(row, tool); + fireTableRowsUpdated(row, row); + } + + public void removeTool(int row) { + tools.remove(row); + fireTableRowsDeleted(row, row); + } + + public AcpToolConfig getToolAt(int row) { + return tools.get(row); + } + + public List getAllTools() { + return new ArrayList<>(tools); + } + + public void clear() { + int size = tools.size(); + if (size > 0) { + tools.clear(); + fireTableRowsDeleted(0, size - 1); + } + } + } + + // ===== ACP Tool Dialog ===== + + private static class AcpToolDialog extends com.intellij.openapi.ui.DialogWrapper { + private final JComboBox typeCombo = new JComboBox<>(AcpToolConfig.AcpType.values()); + private final JBTextField pathField = new JBTextField(); + private final JBCheckBox enabledCheckbox = new JBCheckBox("Enabled", true); + private final JBLabel testResultLabel = new JBLabel(); + private boolean suppressTypeListener = false; + + AcpToolDialog(AcpToolConfig existing) { + super(true); + setTitle(existing == null ? "Add ACP Tool" : "Edit ACP Tool"); + if (existing != null) { + suppressTypeListener = true; + typeCombo.setSelectedItem(existing.getType() != null ? existing.getType() : AcpToolConfig.AcpType.CUSTOM); + suppressTypeListener = false; + pathField.setText(existing.getExecutablePath()); + enabledCheckbox.setSelected(existing.isEnabled()); + } + init(); + if (existing == null) { + onTypeChanged(); + } + } + + @Override + protected JComponent createCenterPanel() { + JPanel panel = new JPanel(new GridBagLayout()); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.insets = new Insets(4, 4, 4, 4); + gbc.anchor = GridBagConstraints.WEST; + + gbc.gridy = 0; + gbc.gridx = 0; + gbc.weightx = 0; + panel.add(new JBLabel("Type:"), gbc); + gbc.gridx = 1; + gbc.weightx = 1.0; + typeCombo.setRenderer(new DefaultListCellRenderer() { + @Override + public java.awt.Component getListCellRendererComponent(JList list, Object value, int index, boolean sel, boolean focus) { + super.getListCellRendererComponent(list, value, index, sel, focus); + if (value instanceof AcpToolConfig.AcpType t) setText(t.getDisplayName()); + return this; + } + }); + typeCombo.addActionListener(e -> onTypeChanged()); + panel.add(typeCombo, gbc); + + gbc.gridy++; + gbc.gridx = 0; + gbc.weightx = 0; + panel.add(new JBLabel("Executable path:"), gbc); + gbc.gridx = 1; + gbc.weightx = 1.0; + panel.add(pathField, gbc); + + gbc.gridy++; + gbc.gridx = 0; + gbc.gridwidth = 2; + panel.add(enabledCheckbox, gbc); + + // Test button row + gbc.gridy++; + gbc.gridx = 0; + gbc.gridwidth = 2; + JPanel testRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 5, 0)); + JButton testButton = new JButton("Test Connection"); + testButton.addActionListener(e -> runTest()); + testRow.add(testButton); + testResultLabel.setForeground(UIManager.getColor("Label.disabledForeground")); + testRow.add(testResultLabel); + panel.add(testRow, gbc); + + return panel; + } + + private void onTypeChanged() { + if (suppressTypeListener) return; + AcpToolConfig.AcpType type = (AcpToolConfig.AcpType) typeCombo.getSelectedItem(); + if (type == null || type == AcpToolConfig.AcpType.CUSTOM) return; + pathField.setText(type.getDefaultExecutablePath()); + } + + private void runTest() { + String path = pathField.getText().trim(); + if (path.isEmpty()) { + testResultLabel.setForeground(UIManager.getColor("Component.errorFocusColor")); + testResultLabel.setText("Executable path is empty"); + return; + } + + testResultLabel.setForeground(UIManager.getColor("Label.disabledForeground")); + testResultLabel.setText("Testing ACP handshake..."); + + com.intellij.openapi.application.ApplicationManager.getApplication().executeOnPooledThread(() -> { + try { + com.devoxx.genie.service.acp.protocol.AcpClient client = + new com.devoxx.genie.service.acp.protocol.AcpClient(text -> {}); + client.start(null, path, "acp"); + client.initialize(); + client.close(); + showTestResult(true, "ACP handshake successful"); + } catch (Exception ex) { + showTestResult(false, ex.getMessage()); + } + }); + } + + private void showTestResult(boolean success, String message) { + SwingUtilities.invokeLater(() -> { + String truncated = message.length() > 80 ? message.substring(0, 80) + "..." : message; + if (success) { + testResultLabel.setForeground(new java.awt.Color(0, 128, 0)); + testResultLabel.setText(truncated); + } else { + testResultLabel.setForeground(UIManager.getColor("Component.errorFocusColor")); + testResultLabel.setText(truncated); + } + }); + } + + public AcpToolConfig getResult() { + AcpToolConfig.AcpType selectedType = (AcpToolConfig.AcpType) typeCombo.getSelectedItem(); + if (selectedType == null) selectedType = AcpToolConfig.AcpType.CUSTOM; + return AcpToolConfig.builder() + .type(selectedType) + .name(selectedType.getDisplayName()) + .executablePath(pathField.getText().trim()) + .enabled(enabledCheckbox.isSelected()) + .build(); + } + } } From 773451e75ea36317a14d524fd5ce7c33bbf02bc2 Mon Sep 17 00:00:00 2001 From: Stephan Janssen Date: Fri, 13 Feb 2026 08:39:40 +0100 Subject: [PATCH 04/10] test: add ACP Runners test suite (40 tests) Comprehensive tests covering the full ACP stack: - JsonRpcMessageTest: message creation, serialization, type detection - AcpTransportTest: process lifecycle, dispatching, handler invocation - AcpClientTest: full protocol flow, error handling, notification filtering - AgentRequestHandlerTest: file I/O, permissions, terminal management - AcpRunnersChatModelFactoryTest: model generation from config - AcpToolConfigTest: builder, equality, enum values - PromptExecutionStrategyFactoryTest: ACP/CLI strategy routing Co-Authored-By: Claude Opus 4.6 --- .../AcpRunnersChatModelFactoryTest.java | 103 ++++++++++ .../genie/model/spec/AcpToolConfigTest.java | 121 +++++++++++ .../service/acp/protocol/AcpClientTest.java | 170 +++++++++++++++ .../acp/protocol/AcpTransportTest.java | 147 +++++++++++++ .../acp/protocol/AgentRequestHandlerTest.java | 193 ++++++++++++++++++ .../acp/protocol/JsonRpcMessageTest.java | 124 +++++++++++ .../PromptExecutionStrategyFactoryTest.java | 28 +++ 7 files changed, 886 insertions(+) create mode 100644 src/test/java/com/devoxx/genie/chatmodel/local/acprunners/AcpRunnersChatModelFactoryTest.java create mode 100644 src/test/java/com/devoxx/genie/model/spec/AcpToolConfigTest.java create mode 100644 src/test/java/com/devoxx/genie/service/acp/protocol/AcpClientTest.java create mode 100644 src/test/java/com/devoxx/genie/service/acp/protocol/AcpTransportTest.java create mode 100644 src/test/java/com/devoxx/genie/service/acp/protocol/AgentRequestHandlerTest.java create mode 100644 src/test/java/com/devoxx/genie/service/acp/protocol/JsonRpcMessageTest.java diff --git a/src/test/java/com/devoxx/genie/chatmodel/local/acprunners/AcpRunnersChatModelFactoryTest.java b/src/test/java/com/devoxx/genie/chatmodel/local/acprunners/AcpRunnersChatModelFactoryTest.java new file mode 100644 index 00000000..4f802803 --- /dev/null +++ b/src/test/java/com/devoxx/genie/chatmodel/local/acprunners/AcpRunnersChatModelFactoryTest.java @@ -0,0 +1,103 @@ +package com.devoxx.genie.chatmodel.local.acprunners; + +import com.devoxx.genie.model.CustomChatModel; +import com.devoxx.genie.model.LanguageModel; +import com.devoxx.genie.model.enumarations.ModelProvider; +import com.devoxx.genie.model.spec.AcpToolConfig; +import com.devoxx.genie.ui.settings.DevoxxGenieStateService; +import dev.langchain4j.model.chat.ChatModel; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AcpRunnersChatModelFactoryTest { + + @Test + void testCreateChatModel_returnsNull() { + AcpRunnersChatModelFactory factory = new AcpRunnersChatModelFactory(); + ChatModel result = factory.createChatModel(new CustomChatModel()); + assertThat(result).isNull(); + } + + @Test + void testGetModels_returnsEnabledToolsOnly() { + try (MockedStatic mockedState = Mockito.mockStatic(DevoxxGenieStateService.class)) { + DevoxxGenieStateService mockState = mock(DevoxxGenieStateService.class); + when(DevoxxGenieStateService.getInstance()).thenReturn(mockState); + + List tools = List.of( + AcpToolConfig.builder() + .type(AcpToolConfig.AcpType.KIMI) + .name("Kimi") + .executablePath("kimi") + .enabled(true) + .build(), + AcpToolConfig.builder() + .type(AcpToolConfig.AcpType.GEMINI) + .name("Gemini") + .executablePath("gemini") + .enabled(false) + .build(), + AcpToolConfig.builder() + .type(AcpToolConfig.AcpType.KILOCODE) + .name("Kilocode") + .executablePath("kilocode") + .enabled(true) + .build() + ); + when(mockState.getAcpTools()).thenReturn(tools); + + AcpRunnersChatModelFactory factory = new AcpRunnersChatModelFactory(); + List models = factory.getModels(); + + assertThat(models).hasSize(2); + assertThat(models.get(0).getModelName()).isEqualTo("Kimi"); + assertThat(models.get(0).getProvider()).isEqualTo(ModelProvider.ACPRunners); + assertThat(models.get(0).isApiKeyUsed()).isFalse(); + assertThat(models.get(1).getModelName()).isEqualTo("Kilocode"); + } + } + + @Test + void testGetModels_emptyWhenNoToolsEnabled() { + try (MockedStatic mockedState = Mockito.mockStatic(DevoxxGenieStateService.class)) { + DevoxxGenieStateService mockState = mock(DevoxxGenieStateService.class); + when(DevoxxGenieStateService.getInstance()).thenReturn(mockState); + + List tools = List.of( + AcpToolConfig.builder() + .type(AcpToolConfig.AcpType.KIMI) + .name("Kimi") + .executablePath("kimi") + .enabled(false) + .build() + ); + when(mockState.getAcpTools()).thenReturn(tools); + + AcpRunnersChatModelFactory factory = new AcpRunnersChatModelFactory(); + List models = factory.getModels(); + + assertThat(models).isEmpty(); + } + } + + @Test + void testGetModels_emptyWhenNoToolsConfigured() { + try (MockedStatic mockedState = Mockito.mockStatic(DevoxxGenieStateService.class)) { + DevoxxGenieStateService mockState = mock(DevoxxGenieStateService.class); + when(DevoxxGenieStateService.getInstance()).thenReturn(mockState); + when(mockState.getAcpTools()).thenReturn(List.of()); + + AcpRunnersChatModelFactory factory = new AcpRunnersChatModelFactory(); + List models = factory.getModels(); + + assertThat(models).isEmpty(); + } + } +} diff --git a/src/test/java/com/devoxx/genie/model/spec/AcpToolConfigTest.java b/src/test/java/com/devoxx/genie/model/spec/AcpToolConfigTest.java new file mode 100644 index 00000000..2e67706c --- /dev/null +++ b/src/test/java/com/devoxx/genie/model/spec/AcpToolConfigTest.java @@ -0,0 +1,121 @@ +package com.devoxx.genie.model.spec; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AcpToolConfigTest { + + @Test + void testBuilder_defaultValues() { + AcpToolConfig config = AcpToolConfig.builder().build(); + + assertThat(config.getType()).isEqualTo(AcpToolConfig.AcpType.CUSTOM); + assertThat(config.getName()).isEmpty(); + assertThat(config.getExecutablePath()).isEmpty(); + assertThat(config.isEnabled()).isTrue(); + } + + @Test + void testBuilder_customValues() { + AcpToolConfig config = AcpToolConfig.builder() + .type(AcpToolConfig.AcpType.KIMI) + .name("Kimi") + .executablePath("/usr/local/bin/kimi") + .enabled(false) + .build(); + + assertThat(config.getType()).isEqualTo(AcpToolConfig.AcpType.KIMI); + assertThat(config.getName()).isEqualTo("Kimi"); + assertThat(config.getExecutablePath()).isEqualTo("/usr/local/bin/kimi"); + assertThat(config.isEnabled()).isFalse(); + } + + @Test + void testAcpType_displayNames() { + assertThat(AcpToolConfig.AcpType.KIMI.getDisplayName()).isEqualTo("Kimi"); + assertThat(AcpToolConfig.AcpType.GEMINI.getDisplayName()).isEqualTo("Gemini"); + assertThat(AcpToolConfig.AcpType.KILOCODE.getDisplayName()).isEqualTo("Kilocode"); + assertThat(AcpToolConfig.AcpType.CUSTOM.getDisplayName()).isEqualTo("Custom"); + } + + @Test + void testAcpType_defaultExecutablePaths() { + assertThat(AcpToolConfig.AcpType.KIMI.getDefaultExecutablePath()).isEqualTo("kimi"); + assertThat(AcpToolConfig.AcpType.GEMINI.getDefaultExecutablePath()).isEqualTo("gemini"); + assertThat(AcpToolConfig.AcpType.KILOCODE.getDefaultExecutablePath()).isEqualTo("kilocode"); + assertThat(AcpToolConfig.AcpType.CUSTOM.getDefaultExecutablePath()).isEmpty(); + } + + @Test + void testAcpType_allValues() { + AcpToolConfig.AcpType[] values = AcpToolConfig.AcpType.values(); + assertThat(values).hasSize(4); + assertThat(values).containsExactly( + AcpToolConfig.AcpType.KIMI, + AcpToolConfig.AcpType.GEMINI, + AcpToolConfig.AcpType.KILOCODE, + AcpToolConfig.AcpType.CUSTOM + ); + } + + @Test + void testEquality() { + AcpToolConfig config1 = AcpToolConfig.builder() + .type(AcpToolConfig.AcpType.KIMI) + .name("Kimi") + .executablePath("kimi") + .enabled(true) + .build(); + + AcpToolConfig config2 = AcpToolConfig.builder() + .type(AcpToolConfig.AcpType.KIMI) + .name("Kimi") + .executablePath("kimi") + .enabled(true) + .build(); + + assertThat(config1).isEqualTo(config2); + assertThat(config1.hashCode()).isEqualTo(config2.hashCode()); + } + + @Test + void testInequality_differentEnabled() { + AcpToolConfig config1 = AcpToolConfig.builder() + .type(AcpToolConfig.AcpType.KIMI) + .name("Kimi") + .enabled(true) + .build(); + + AcpToolConfig config2 = AcpToolConfig.builder() + .type(AcpToolConfig.AcpType.KIMI) + .name("Kimi") + .enabled(false) + .build(); + + assertThat(config1).isNotEqualTo(config2); + } + + @Test + void testNoArgConstructor() { + AcpToolConfig config = new AcpToolConfig(); + assertThat(config.getType()).isEqualTo(AcpToolConfig.AcpType.CUSTOM); + assertThat(config.getName()).isEmpty(); + assertThat(config.getExecutablePath()).isEmpty(); + assertThat(config.isEnabled()).isTrue(); + } + + @Test + void testSetters() { + AcpToolConfig config = new AcpToolConfig(); + config.setType(AcpToolConfig.AcpType.GEMINI); + config.setName("Gemini"); + config.setExecutablePath("/usr/bin/gemini"); + config.setEnabled(false); + + assertThat(config.getType()).isEqualTo(AcpToolConfig.AcpType.GEMINI); + assertThat(config.getName()).isEqualTo("Gemini"); + assertThat(config.getExecutablePath()).isEqualTo("/usr/bin/gemini"); + assertThat(config.isEnabled()).isFalse(); + } +} diff --git a/src/test/java/com/devoxx/genie/service/acp/protocol/AcpClientTest.java b/src/test/java/com/devoxx/genie/service/acp/protocol/AcpClientTest.java new file mode 100644 index 00000000..0e211330 --- /dev/null +++ b/src/test/java/com/devoxx/genie/service/acp/protocol/AcpClientTest.java @@ -0,0 +1,170 @@ +package com.devoxx.genie.service.acp.protocol; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AcpClientTest { + + private AcpClient client; + + @AfterEach + void tearDown() { + if (client != null) { + client.close(); + } + } + + @Test + void testConstructor_setsUpHandlers() { + List output = new ArrayList<>(); + client = new AcpClient(output::add); + // Should not throw — handlers are wired internally + assertThat(client).isNotNull(); + } + + @Test + void testClose_idempotent() { + client = new AcpClient(text -> {}); + client.close(); + client.close(); + // Should not throw + } + + @Test + void testSendPrompt_withoutSession_throwsIllegalState() { + client = new AcpClient(text -> {}); + + assertThatThrownBy(() -> client.sendPrompt("hello")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("No active session"); + } + + @Test + void testInitialize_successfulHandshake() throws Exception { + // Create a script that simulates an ACP server: + // 1. Reads the initialize request + // 2. Responds with a valid initialize result + String script = "read line; echo '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"1\"}}'"; + + client = new AcpClient(text -> {}); + client.start(null, "sh", "-c", script); + client.initialize(); + // If we get here without exception, initialize succeeded + } + + @Test + void testInitialize_errorResponse_throws() { + // Create a script that responds with an error + String script = "read line; echo '{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-32600,\"message\":\"Bad request\"}}'"; + + client = new AcpClient(text -> {}); + + assertThatThrownBy(() -> { + client.start(null, "sh", "-c", script); + client.initialize(); + }).isInstanceOf(RuntimeException.class) + .hasMessageContaining("Initialize failed"); + } + + @Test + void testCreateSession_successfulHandshake() throws Exception { + // Script handles both initialize and session/new requests + String script = String.join("; ", + "read line; echo '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"1\"}}'", + "read line; echo '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"sessionId\":\"test-session-123\"}}'" + ); + + client = new AcpClient(text -> {}); + client.start(null, "sh", "-c", script); + client.initialize(); + client.createSession("/tmp/test"); + // If we get here without exception, session creation succeeded + } + + @Test + void testCreateSession_errorResponse_throws() { + String script = String.join("; ", + "read line; echo '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"1\"}}'", + "read line; echo '{\"jsonrpc\":\"2.0\",\"id\":2,\"error\":{\"code\":-1,\"message\":\"Session failed\"}}'" + ); + + client = new AcpClient(text -> {}); + + assertThatThrownBy(() -> { + client.start(null, "sh", "-c", script); + client.initialize(); + client.createSession("/tmp/test"); + }).isInstanceOf(RuntimeException.class) + .hasMessageContaining("session/new failed"); + } + + @Test + void testFullFlow_initializeCreateSessionSendPrompt() throws Exception { + // Full ACP flow: initialize → session/new → session/prompt + // Also sends a session/update notification with an agent_message_chunk + String script = String.join("; ", + "read line; echo '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"1\"}}'", + "read line; echo '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"sessionId\":\"s1\"}}'", + "read line; echo '{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"s1\",\"update\":{\"sessionUpdate\":\"agent_message_chunk\",\"content\":{\"type\":\"text\",\"text\":\"Hello!\"}}}}'", + "sleep 0.1", + "echo '{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{}}'" + ); + + List receivedText = new ArrayList<>(); + CountDownLatch textLatch = new CountDownLatch(1); + + client = new AcpClient(text -> { + receivedText.add(text); + textLatch.countDown(); + }); + client.start(null, "sh", "-c", script); + client.initialize(); + client.createSession("/tmp/test"); + client.sendPrompt("Say hello"); + + // Wait for the notification to be processed + boolean received = textLatch.await(5, TimeUnit.SECONDS); + assertThat(received).isTrue(); + assertThat(receivedText).containsExactly("Hello!"); + } + + @Test + void testNotification_thoughtChunksFiltered() throws Exception { + // Thought chunks should be filtered out, only agent_message_chunk with text type passes through + String script = String.join("; ", + "read line; echo '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"1\"}}'", + "read line; echo '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"sessionId\":\"s1\"}}'", + // Thought chunk — should be filtered + "read line; echo '{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"s1\",\"update\":{\"sessionUpdate\":\"thought_chunk\",\"content\":{\"type\":\"text\",\"text\":\"thinking...\"}}}}'", + // Agent message chunk — should pass through + "echo '{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"s1\",\"update\":{\"sessionUpdate\":\"agent_message_chunk\",\"content\":{\"type\":\"text\",\"text\":\"Result\"}}}}'", + "sleep 0.1", + "echo '{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{}}'" + ); + + List receivedText = new ArrayList<>(); + CountDownLatch latch = new CountDownLatch(1); + + client = new AcpClient(text -> { + receivedText.add(text); + latch.countDown(); + }); + client.start(null, "sh", "-c", script); + client.initialize(); + client.createSession("/tmp/test"); + client.sendPrompt("test"); + + boolean received = latch.await(5, TimeUnit.SECONDS); + assertThat(received).isTrue(); + // Only "Result" should be received, not "thinking..." + assertThat(receivedText).containsExactly("Result"); + } +} diff --git a/src/test/java/com/devoxx/genie/service/acp/protocol/AcpTransportTest.java b/src/test/java/com/devoxx/genie/service/acp/protocol/AcpTransportTest.java new file mode 100644 index 00000000..cc5c807e --- /dev/null +++ b/src/test/java/com/devoxx/genie/service/acp/protocol/AcpTransportTest.java @@ -0,0 +1,147 @@ +package com.devoxx.genie.service.acp.protocol; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.io.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AcpTransportTest { + + private AcpTransport transport; + + @AfterEach + void tearDown() { + if (transport != null) { + transport.close(); + } + } + + @Test + void testStart_launchesProcess() throws Exception { + transport = new AcpTransport(); + // Use a simple command that writes JSON-RPC to stdout + // "echo" just outputs and exits, which is fine for verifying process startup + transport.start(null, "echo", "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}"); + // If we get here without exception, the process started successfully + } + + @Test + void testClose_cancelsAllPendingRequests() throws Exception { + transport = new AcpTransport(); + // Start a process that stays alive (cat reads stdin indefinitely) + transport.start(null, "cat"); + + // The transport is now running with a live process. + // Close it — all pending futures should be cancelled. + transport.close(); + // Should not throw, process should be destroyed + } + + @Test + void testClose_idempotent() { + transport = new AcpTransport(); + // Close without starting — should not throw + transport.close(); + transport.close(); + } + + @Test + void testNotificationHandler_invoked() throws Exception { + transport = new AcpTransport(); + + AtomicReference received = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + + transport.setNotificationHandler(msg -> { + received.set(msg); + latch.countDown(); + }); + + // Start a process that sends a notification (method present, no id) + String notification = "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{}}"; + transport.start(null, "echo", notification); + + boolean received1 = latch.await(5, TimeUnit.SECONDS); + assertThat(received1).isTrue(); + assertThat(received.get()).isNotNull(); + assertThat(received.get().method).isEqualTo("session/update"); + assertThat(received.get().isNotification()).isTrue(); + } + + @Test + void testRequestHandler_invoked() throws Exception { + transport = new AcpTransport(); + + AtomicReference received = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + + transport.setRequestHandler(msg -> { + received.set(msg); + latch.countDown(); + }); + + // Start a process that sends a request (method present + id present) + String request = "{\"jsonrpc\":\"2.0\",\"id\":99,\"method\":\"fs/read_text_file\",\"params\":{\"path\":\"/tmp/test\"}}"; + transport.start(null, "echo", request); + + boolean received1 = latch.await(5, TimeUnit.SECONDS); + assertThat(received1).isTrue(); + assertThat(received.get()).isNotNull(); + assertThat(received.get().method).isEqualTo("fs/read_text_file"); + assertThat(received.get().isRequest()).isTrue(); + assertThat(received.get().id).isEqualTo(99); + } + + @Test + void testResponseDispatching_completesFuture() throws Exception { + transport = new AcpTransport(); + + // Start a process that echoes back what we write to it + // We use a shell script that reads a line and then outputs a response + String script = "read line; echo '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"1\"}}'"; + transport.start(null, "sh", "-c", script); + + // Send a request — the script will respond with id=1 + JsonRpcMessage response = transport.sendRequest("initialize", null); + + assertThat(response).isNotNull(); + assertThat(response.isResponse()).isTrue(); + assertThat(response.id).isEqualTo(1); + assertThat(response.result.get("protocolVersion").asText()).isEqualTo("1"); + } + + @Test + void testSendRequest_parsesEmptyLines() throws Exception { + transport = new AcpTransport(); + + // Script outputs blank lines before the response + String script = "read line; echo ''; echo ''; echo '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}'"; + transport.start(null, "sh", "-c", script); + + JsonRpcMessage response = transport.sendRequest("test", null); + + assertThat(response).isNotNull(); + assertThat(response.isResponse()).isTrue(); + } + + @Test + void testMapper_configuration() { + // Verify ObjectMapper is configured to ignore unknown properties + assertThat(AcpTransport.MAPPER).isNotNull(); + + // Parse a message with unknown fields — should not throw + try { + JsonRpcMessage msg = AcpTransport.MAPPER.readValue( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"unknownField\":42}", + JsonRpcMessage.class); + assertThat(msg.id).isEqualTo(1); + } catch (Exception e) { + throw new AssertionError("MAPPER should ignore unknown properties", e); + } + } +} diff --git a/src/test/java/com/devoxx/genie/service/acp/protocol/AgentRequestHandlerTest.java b/src/test/java/com/devoxx/genie/service/acp/protocol/AgentRequestHandlerTest.java new file mode 100644 index 00000000..397ed4fd --- /dev/null +++ b/src/test/java/com/devoxx/genie/service/acp/protocol/AgentRequestHandlerTest.java @@ -0,0 +1,193 @@ +package com.devoxx.genie.service.acp.protocol; + +import com.fasterxml.jackson.databind.JsonNode; +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 java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class AgentRequestHandlerTest { + + private AcpTransport mockTransport; + private AgentRequestHandler handler; + + @TempDir + Path tempDir; + + @BeforeEach + void setUp() { + mockTransport = mock(AcpTransport.class); + handler = new AgentRequestHandler(mockTransport); + } + + @Test + void testHandleFsRead_readsFileContent() throws Exception { + // Create a test file + Path testFile = tempDir.resolve("test.txt"); + Files.writeString(testFile, "Hello, ACP!"); + + // Build a request message + JsonRpcMessage msg = new JsonRpcMessage(); + msg.id = 1; + msg.method = "fs/read_text_file"; + msg.params = AcpTransport.MAPPER.valueToTree(Map.of("path", testFile.toString())); + + handler.handle(msg); + + // Verify response was sent with file content + verify(mockTransport).sendResponse(eq(1), argThat(result -> { + @SuppressWarnings("unchecked") + Map map = (Map) result; + return "Hello, ACP!".equals(map.get("content")); + })); + } + + @Test + void testHandleFsWrite_writesFileContent() throws Exception { + Path testFile = tempDir.resolve("output.txt"); + + JsonRpcMessage msg = new JsonRpcMessage(); + msg.id = 2; + msg.method = "fs/write_text_file"; + msg.params = AcpTransport.MAPPER.valueToTree(Map.of( + "path", testFile.toString(), + "content", "Written by ACP" + )); + + handler.handle(msg); + + // Verify file was written + assertThat(Files.readString(testFile)).isEqualTo("Written by ACP"); + + // Verify success response + verify(mockTransport).sendResponse(eq(2), argThat(result -> { + @SuppressWarnings("unchecked") + Map map = (Map) result; + return Boolean.TRUE.equals(map.get("success")); + })); + } + + @Test + void testHandleFsWrite_createsParentDirectories() throws Exception { + Path testFile = tempDir.resolve("sub/dir/file.txt"); + + JsonRpcMessage msg = new JsonRpcMessage(); + msg.id = 3; + msg.method = "fs/write_text_file"; + msg.params = AcpTransport.MAPPER.valueToTree(Map.of( + "path", testFile.toString(), + "content", "nested" + )); + + handler.handle(msg); + + assertThat(Files.exists(testFile)).isTrue(); + assertThat(Files.readString(testFile)).isEqualTo("nested"); + } + + @Test + void testHandleRequestPermission_autoApproves() throws Exception { + JsonRpcMessage msg = new JsonRpcMessage(); + msg.id = 4; + msg.method = "session/request_permission"; + msg.params = AcpTransport.MAPPER.valueToTree(Map.of("permission", "fs_write")); + + handler.handle(msg); + + verify(mockTransport).sendResponse(eq(4), argThat(result -> { + @SuppressWarnings("unchecked") + Map map = (Map) result; + return Boolean.TRUE.equals(map.get("granted")); + })); + } + + @Test + void testHandleUnknownMethod_sendsError() throws Exception { + JsonRpcMessage msg = new JsonRpcMessage(); + msg.id = 5; + msg.method = "unknown/method"; + msg.params = AcpTransport.MAPPER.valueToTree(Map.of()); + + handler.handle(msg); + + verify(mockTransport).sendErrorResponse(eq(5), eq(-32603), contains("Unknown method")); + } + + @Test + void testHandleFsRead_nonExistentFile_sendsError() throws Exception { + JsonRpcMessage msg = new JsonRpcMessage(); + msg.id = 6; + msg.method = "fs/read_text_file"; + msg.params = AcpTransport.MAPPER.valueToTree(Map.of("path", "/nonexistent/file.txt")); + + handler.handle(msg); + + // Should send an error response because the file doesn't exist + verify(mockTransport).sendErrorResponse(eq(6), eq(-32603), any()); + } + + @Test + void testHandleTerminalCreate_startsProcess() throws Exception { + JsonRpcMessage msg = new JsonRpcMessage(); + msg.id = 7; + msg.method = "terminal/create"; + msg.params = AcpTransport.MAPPER.valueToTree(Map.of( + "command", "echo hello", + "cwd", tempDir.toString() + )); + + handler.handle(msg); + + // Verify a response with terminalId was sent + verify(mockTransport).sendResponse(eq(7), argThat(result -> { + @SuppressWarnings("unchecked") + Map map = (Map) result; + return map.containsKey("terminalId"); + })); + } + + @Test + void testHandleTerminalOutput_unknownTerminal() throws Exception { + JsonRpcMessage msg = new JsonRpcMessage(); + msg.id = 8; + msg.method = "terminal/output"; + msg.params = AcpTransport.MAPPER.valueToTree(Map.of("terminalId", "nonexistent")); + + handler.handle(msg); + + verify(mockTransport).sendResponse(eq(8), argThat(result -> { + @SuppressWarnings("unchecked") + Map map = (Map) result; + return map.containsKey("error"); + })); + } + + @Test + void testHandleTerminalKill_unknownTerminal_noError() throws Exception { + JsonRpcMessage msg = new JsonRpcMessage(); + msg.id = 9; + msg.method = "terminal/kill"; + msg.params = AcpTransport.MAPPER.valueToTree(Map.of("terminalId", "nonexistent")); + + handler.handle(msg); + + verify(mockTransport).sendResponse(eq(9), argThat(result -> { + @SuppressWarnings("unchecked") + Map map = (Map) result; + return Boolean.TRUE.equals(map.get("success")); + })); + } + + private static String contains(String substring) { + return argThat(s -> s != null && s.contains(substring)); + } +} diff --git a/src/test/java/com/devoxx/genie/service/acp/protocol/JsonRpcMessageTest.java b/src/test/java/com/devoxx/genie/service/acp/protocol/JsonRpcMessageTest.java new file mode 100644 index 00000000..7c8cc4d9 --- /dev/null +++ b/src/test/java/com/devoxx/genie/service/acp/protocol/JsonRpcMessageTest.java @@ -0,0 +1,124 @@ +package com.devoxx.genie.service.acp.protocol; + +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class JsonRpcMessageTest { + + @Test + void testRequest_hasMethodAndId() { + JsonRpcMessage msg = JsonRpcMessage.request(1, "initialize", Map.of("key", "value")); + + assertThat(msg.isRequest()).isTrue(); + assertThat(msg.isNotification()).isFalse(); + assertThat(msg.isResponse()).isFalse(); + assertThat(msg.id).isEqualTo(1); + assertThat(msg.method).isEqualTo("initialize"); + assertThat(msg.jsonrpc).isEqualTo("2.0"); + assertThat(msg.params).isNotNull(); + } + + @Test + void testRequest_withNullParams() { + JsonRpcMessage msg = JsonRpcMessage.request(2, "session/new", null); + + assertThat(msg.isRequest()).isTrue(); + assertThat(msg.params).isNull(); + } + + @Test + void testResponse_hasIdAndResult() { + JsonRpcMessage msg = JsonRpcMessage.response(1, Map.of("protocolVersion", "1")); + + assertThat(msg.isResponse()).isTrue(); + assertThat(msg.isRequest()).isFalse(); + assertThat(msg.isNotification()).isFalse(); + assertThat(msg.id).isEqualTo(1); + assertThat(msg.result).isNotNull(); + assertThat(msg.error).isNull(); + } + + @Test + void testErrorResponse_hasIdAndError() { + JsonRpcMessage msg = JsonRpcMessage.errorResponse(1, -32603, "Internal error"); + + assertThat(msg.isResponse()).isTrue(); + assertThat(msg.id).isEqualTo(1); + assertThat(msg.error).isNotNull(); + assertThat(msg.error.code).isEqualTo(-32603); + assertThat(msg.error.message).isEqualTo("Internal error"); + assertThat(msg.result).isNull(); + } + + @Test + void testNotification_hasMethodButNoId() { + JsonRpcMessage msg = new JsonRpcMessage(); + msg.method = "session/update"; + // id is null by default + + assertThat(msg.isNotification()).isTrue(); + assertThat(msg.isRequest()).isFalse(); + assertThat(msg.isResponse()).isFalse(); + } + + @Test + void testSerialization_roundTrip() throws Exception { + JsonRpcMessage original = JsonRpcMessage.request(42, "session/prompt", + Map.of("sessionId", "abc", "text", "hello")); + + String json = AcpTransport.MAPPER.writeValueAsString(original); + JsonRpcMessage deserialized = AcpTransport.MAPPER.readValue(json, JsonRpcMessage.class); + + assertThat(deserialized.id).isEqualTo(42); + assertThat(deserialized.method).isEqualTo("session/prompt"); + assertThat(deserialized.jsonrpc).isEqualTo("2.0"); + assertThat(deserialized.isRequest()).isTrue(); + } + + @Test + void testSerialization_excludesNullFields() throws Exception { + JsonRpcMessage msg = JsonRpcMessage.request(1, "test", null); + + String json = AcpTransport.MAPPER.writeValueAsString(msg); + + assertThat(json).contains("\"id\""); + assertThat(json).contains("\"method\""); + assertThat(json).doesNotContain("\"params\""); + assertThat(json).doesNotContain("\"result\""); + assertThat(json).doesNotContain("\"error\""); + } + + @Test + void testDeserialization_unknownFieldsIgnored() throws Exception { + String json = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"test\",\"unknownField\":true}"; + + JsonRpcMessage msg = AcpTransport.MAPPER.readValue(json, JsonRpcMessage.class); + + assertThat(msg.id).isEqualTo(1); + assertThat(msg.method).isEqualTo("test"); + } + + @Test + void testResponse_resultCanBeComplex() throws Exception { + Map resultData = Map.of( + "protocolVersion", "1", + "capabilities", Map.of("streaming", true) + ); + JsonRpcMessage msg = JsonRpcMessage.response(1, resultData); + + assertThat(msg.result.has("protocolVersion")).isTrue(); + assertThat(msg.result.get("protocolVersion").asText()).isEqualTo("1"); + assertThat(msg.result.has("capabilities")).isTrue(); + } + + @Test + void testJsonRpcError_defaultConstructor() { + JsonRpcMessage.JsonRpcError error = new JsonRpcMessage.JsonRpcError(); + assertThat(error.code).isEqualTo(0); + assertThat(error.message).isNull(); + } +} diff --git a/src/test/java/com/devoxx/genie/service/prompt/strategy/PromptExecutionStrategyFactoryTest.java b/src/test/java/com/devoxx/genie/service/prompt/strategy/PromptExecutionStrategyFactoryTest.java index 634a8610..748d88d6 100644 --- a/src/test/java/com/devoxx/genie/service/prompt/strategy/PromptExecutionStrategyFactoryTest.java +++ b/src/test/java/com/devoxx/genie/service/prompt/strategy/PromptExecutionStrategyFactoryTest.java @@ -1,5 +1,7 @@ package com.devoxx.genie.service.prompt.strategy; +import com.devoxx.genie.model.LanguageModel; +import com.devoxx.genie.model.enumarations.ModelProvider; import com.devoxx.genie.model.request.ChatMessageContext; import com.devoxx.genie.ui.settings.DevoxxGenieStateService; import com.intellij.openapi.project.Project; @@ -87,6 +89,32 @@ public void testCreateStrategy_NonStreaming() { } } + @Test + public void testCreateStrategy_AcpRunners() { + LanguageModel acpModel = LanguageModel.builder() + .provider(ModelProvider.ACPRunners) + .modelName("Kimi") + .build(); + when(context.getLanguageModel()).thenReturn(acpModel); + + PromptExecutionStrategy strategy = factory.createStrategy(context); + + assertTrue(strategy instanceof AcpPromptStrategy); + } + + @Test + public void testCreateStrategy_CliRunners() { + LanguageModel cliModel = LanguageModel.builder() + .provider(ModelProvider.CLIRunners) + .modelName("Claude") + .build(); + when(context.getLanguageModel()).thenReturn(cliModel); + + PromptExecutionStrategy strategy = factory.createStrategy(context); + + assertTrue(strategy instanceof CliPromptStrategy); + } + @Test public void testCreateStrategy_StreamingNull() { // Set up context without web search From 2b41b9f917ec8b21faa1f32787f725392792c7ea Mon Sep 17 00:00:00 2001 From: Stephan Janssen Date: Fri, 13 Feb 2026 09:57:00 +0100 Subject: [PATCH 05/10] feat: add Claude/Copilot ACP types and per-tool acpFlag Add CLAUDE and COPILOT to AcpType enum with their default executable paths and ACP flags (Copilot uses --acp). Add configurable acpFlag field to AcpToolConfig so each tool can specify its own flag. Use the configured acpFlag when starting the ACP client process. Co-Authored-By: Claude Opus 4.6 --- .../devoxx/genie/model/spec/AcpToolConfig.java | 16 +++++++++++----- .../prompt/strategy/AcpPromptStrategy.java | 5 +++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/devoxx/genie/model/spec/AcpToolConfig.java b/src/main/java/com/devoxx/genie/model/spec/AcpToolConfig.java index ae71ea98..175a951c 100644 --- a/src/main/java/com/devoxx/genie/model/spec/AcpToolConfig.java +++ b/src/main/java/com/devoxx/genie/model/spec/AcpToolConfig.java @@ -14,17 +14,21 @@ public class AcpToolConfig { @Getter public enum AcpType { - KIMI("Kimi", "kimi"), - GEMINI("Gemini", "gemini"), - KILOCODE("Kilocode", "kilocode"), - CUSTOM("Custom", ""); + CLAUDE("Claude", "claude-code-acp", "acp"), + COPILOT("Copilot", "copilot", "--acp"), + KIMI("Kimi", "kimi", "acp"), + GEMINI("Gemini", "gemini", "acp"), + KILOCODE("Kilocode", "kilocode", "acp"), + CUSTOM("Custom", "", "acp"); private final String displayName; private final String defaultExecutablePath; + private final String defaultAcpFlag; - AcpType(String displayName, String defaultExecutablePath) { + AcpType(String displayName, String defaultExecutablePath, String defaultAcpFlag) { this.displayName = displayName; this.defaultExecutablePath = defaultExecutablePath; + this.defaultAcpFlag = defaultAcpFlag; } } @@ -35,5 +39,7 @@ public enum AcpType { @Builder.Default private String executablePath = ""; @Builder.Default + private String acpFlag = "acp"; + @Builder.Default private boolean enabled = true; } diff --git a/src/main/java/com/devoxx/genie/service/prompt/strategy/AcpPromptStrategy.java b/src/main/java/com/devoxx/genie/service/prompt/strategy/AcpPromptStrategy.java index 7b096c6e..7a03403a 100644 --- a/src/main/java/com/devoxx/genie/service/prompt/strategy/AcpPromptStrategy.java +++ b/src/main/java/com/devoxx/genie/service/prompt/strategy/AcpPromptStrategy.java @@ -52,7 +52,7 @@ protected void executeStrategySpecific(@NotNull ChatMessageContext context, return; } - String prompt = context.getUserPrompt(); + String prompt = buildPromptWithHistory(context); String executablePath = acpTool.getExecutablePath(); log.info("ACP execute: tool={}, executable={}", toolName, executablePath); @@ -103,7 +103,8 @@ private void runAcpSession(@NotNull ChatMessageContext context, File cwd = basePath != null ? new File(basePath) : null; consoleManager.printSystem("[ACP] Starting " + acpTool.getName() + "..."); - client.start(cwd, acpTool.getExecutablePath(), "acp"); + String acpFlag = acpTool.getAcpFlag() != null ? acpTool.getAcpFlag() : "acp"; + client.start(cwd, acpTool.getExecutablePath(), acpFlag); consoleManager.printSystem("[ACP] Initializing protocol..."); client.initialize(); From 702a265db0db23801d693d46225d1a1571a99fa6 Mon Sep 17 00:00:00 2001 From: Stephan Janssen Date: Fri, 13 Feb 2026 09:57:06 +0100 Subject: [PATCH 06/10] refactor: move CLI/ACP runner settings to dedicated settings page Extract CLI and ACP runner table management from SpecSettingsComponent into a new RunnerSettingsComponent under ui/settings/runner/. Register it as a separate "CLI/ACP Runners" configurable in plugin.xml. This keeps the Spec Driven Development settings focused on spec/backlog configuration. Co-Authored-By: Claude Opus 4.6 --- .../runner/RunnerSettingsComponent.java | 951 ++++++++++++++++++ .../runner/RunnerSettingsConfigurable.java | 65 ++ .../settings/spec/SpecSettingsComponent.java | 832 +-------------- src/main/resources/META-INF/plugin.xml | 5 + 4 files changed, 1023 insertions(+), 830 deletions(-) create mode 100644 src/main/java/com/devoxx/genie/ui/settings/runner/RunnerSettingsComponent.java create mode 100644 src/main/java/com/devoxx/genie/ui/settings/runner/RunnerSettingsConfigurable.java diff --git a/src/main/java/com/devoxx/genie/ui/settings/runner/RunnerSettingsComponent.java b/src/main/java/com/devoxx/genie/ui/settings/runner/RunnerSettingsComponent.java new file mode 100644 index 00000000..bb752bee --- /dev/null +++ b/src/main/java/com/devoxx/genie/ui/settings/runner/RunnerSettingsComponent.java @@ -0,0 +1,951 @@ +package com.devoxx.genie.ui.settings.runner; + +import com.devoxx.genie.model.spec.AcpToolConfig; +import com.devoxx.genie.model.spec.CliToolConfig; +import com.devoxx.genie.ui.settings.AbstractSettingsComponent; +import com.devoxx.genie.ui.settings.DevoxxGenieStateService; +import com.intellij.openapi.project.Project; +import com.intellij.ui.ToolbarDecorator; +import com.intellij.ui.components.JBCheckBox; +import com.intellij.ui.components.JBLabel; +import com.intellij.ui.components.JBTextField; +import com.intellij.ui.table.JBTable; +import org.jetbrains.annotations.NotNull; + +import javax.swing.*; +import javax.swing.table.AbstractTableModel; +import java.awt.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Settings panel for CLI and ACP Runners configuration. + */ +public class RunnerSettingsComponent extends AbstractSettingsComponent { + + private final Project project; + + private final CliToolTableModel cliToolTableModel = new CliToolTableModel(); + private final JBTable cliToolTable = new JBTable(cliToolTableModel); + + private final AcpToolTableModel acpToolTableModel = new AcpToolTableModel(); + private final JBTable acpToolTable = new JBTable(acpToolTableModel); + + public RunnerSettingsComponent(@NotNull Project project) { + this.project = project; + JPanel contentPanel = new JPanel(new GridBagLayout()); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.weightx = 1.0; + gbc.gridwidth = 1; + gbc.gridx = 0; + gbc.anchor = GridBagConstraints.NORTHWEST; + gbc.insets = new Insets(4, 5, 4, 5); + gbc.gridy = 0; + + // --- CLI Runners --- + addSection(contentPanel, gbc, "CLI Runners"); + + addHelpText(contentPanel, gbc, + "Configure external CLI tools (e.g., GitHub Copilot CLI, Claude Code, Gemini CLI) " + + "that can execute spec tasks instead of the built-in LLM provider. " + + "CLI tools must have the Backlog MCP server installed so they can update task status. " + + "Select the execution mode in the Spec Browser toolbar."); + + // Configure table columns + cliToolTable.getColumnModel().getColumn(0).setMaxWidth(60); // Enabled + cliToolTable.getColumnModel().getColumn(0).setMinWidth(60); + cliToolTable.getColumnModel().getColumn(1).setPreferredWidth(80); // Type + cliToolTable.getColumnModel().getColumn(2).setPreferredWidth(220); // Path + cliToolTable.getColumnModel().getColumn(3).setPreferredWidth(170); // MCP Config Flag + cliToolTable.setRowHeight(25); + + ToolbarDecorator decorator = ToolbarDecorator.createDecorator(cliToolTable) + .setAddAction(button -> addCliTool()) + .setEditAction(button -> editCliTool()) + .setRemoveAction(button -> removeCliTool()); + + JPanel tablePanel = decorator.createPanel(); + tablePanel.setPreferredSize(new Dimension(-1, 150)); + + gbc.gridwidth = 2; + gbc.gridx = 0; + gbc.fill = GridBagConstraints.BOTH; + contentPanel.add(tablePanel, gbc); + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.gridy++; + + // Load existing CLI tools from state + loadCliTools(); + + // --- ACP Runners --- + addSection(contentPanel, gbc, "ACP Runners"); + + addHelpText(contentPanel, gbc, + "Configure external ACP (Agent Communication Protocol) tools that communicate " + + "via JSON-RPC 2.0 over stdin/stdout. ACP provides structured streaming, file operations, " + + "terminal management, and capability negotiation. " + + "Supported CLIs: Kimi, Gemini CLI, Kilocode."); + + // Configure ACP table columns + acpToolTable.getColumnModel().getColumn(0).setMaxWidth(60); // Enabled + acpToolTable.getColumnModel().getColumn(0).setMinWidth(60); + acpToolTable.getColumnModel().getColumn(1).setPreferredWidth(100); // Type + acpToolTable.getColumnModel().getColumn(2).setPreferredWidth(270); // Path + acpToolTable.setRowHeight(25); + + ToolbarDecorator acpDecorator = ToolbarDecorator.createDecorator(acpToolTable) + .setAddAction(button -> addAcpTool()) + .setEditAction(button -> editAcpTool()) + .setRemoveAction(button -> removeAcpTool()); + + JPanel acpTablePanel = acpDecorator.createPanel(); + acpTablePanel.setPreferredSize(new Dimension(-1, 150)); + + gbc.gridwidth = 2; + gbc.gridx = 0; + gbc.fill = GridBagConstraints.BOTH; + contentPanel.add(acpTablePanel, gbc); + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.gridy++; + + // Load existing ACP tools from state + loadAcpTools(); + + // Filler + gbc.weighty = 1.0; + gbc.gridy++; + contentPanel.add(Box.createVerticalGlue(), gbc); + + panel.add(contentPanel, BorderLayout.NORTH); + } + + // ===== CLI Tool Table Management ===== + + private void loadCliTools() { + List tools = stateService.getCliTools(); + if (tools != null) { + for (CliToolConfig tool : tools) { + cliToolTableModel.addTool(tool); + } + } + // Pre-populate with Copilot if no tools configured + if (cliToolTableModel.getRowCount() == 0) { + com.devoxx.genie.service.cli.command.CliCommand cmd = CliToolConfig.CliType.COPILOT.createCommand(); + List defaultArgs = new ArrayList<>(); + for (String arg : cmd.defaultExtraArgs().split("\\s+")) { + if (!arg.isEmpty()) defaultArgs.add(arg); + } + cliToolTableModel.addTool(CliToolConfig.builder() + .type(CliToolConfig.CliType.COPILOT) + .name(CliToolConfig.CliType.COPILOT.getDisplayName()) + .executablePath(cmd.defaultExecutablePath()) + .extraArgs(defaultArgs) + .mcpConfigFlag(cmd.defaultMcpConfigFlag()) + .enabled(true) + .build()); + } + } + + private void addCliTool() { + CliToolDialog dialog = new CliToolDialog(null); + if (dialog.showAndGet()) { + cliToolTableModel.addTool(dialog.getResult()); + } + } + + private void editCliTool() { + int row = cliToolTable.getSelectedRow(); + if (row < 0) return; + CliToolConfig existing = cliToolTableModel.getToolAt(row); + CliToolDialog dialog = new CliToolDialog(existing); + if (dialog.showAndGet()) { + cliToolTableModel.updateTool(row, dialog.getResult()); + } + } + + private void removeCliTool() { + int row = cliToolTable.getSelectedRow(); + if (row >= 0) { + cliToolTableModel.removeTool(row); + } + } + + // ===== ACP Tool Table Management ===== + + private void loadAcpTools() { + List tools = stateService.getAcpTools(); + if (tools != null) { + for (AcpToolConfig tool : tools) { + acpToolTableModel.addTool(tool); + } + } + } + + private void addAcpTool() { + AcpToolDialog dialog = new AcpToolDialog(null); + if (dialog.showAndGet()) { + acpToolTableModel.addTool(dialog.getResult()); + } + } + + private void editAcpTool() { + int row = acpToolTable.getSelectedRow(); + if (row < 0) return; + AcpToolConfig existing = acpToolTableModel.getToolAt(row); + AcpToolDialog dialog = new AcpToolDialog(existing); + if (dialog.showAndGet()) { + acpToolTableModel.updateTool(row, dialog.getResult()); + } + } + + private void removeAcpTool() { + int row = acpToolTable.getSelectedRow(); + if (row >= 0) { + acpToolTableModel.removeTool(row); + } + } + + // ===== Helper Methods ===== + + private void addFullWidthRow(JPanel panel, GridBagConstraints gbc, JComponent component) { + gbc.gridwidth = 2; + gbc.gridx = 0; + panel.add(component, gbc); + gbc.gridy++; + } + + private void addHelpText(JPanel panel, GridBagConstraints gbc, String text) { + gbc.gridwidth = 2; + gbc.gridx = 0; + gbc.insets = new Insets(0, 25, 8, 5); + JTextArea helpArea = new JTextArea(text); + helpArea.setLineWrap(true); + helpArea.setWrapStyleWord(true); + helpArea.setEditable(false); + helpArea.setFocusable(false); + helpArea.setOpaque(false); + helpArea.setBorder(null); + helpArea.setFont(UIManager.getFont("Label.font").deriveFont((float) UIManager.getFont("Label.font").getSize() - 1)); + helpArea.setForeground(UIManager.getColor("Label.disabledForeground")); + panel.add(helpArea, gbc); + gbc.insets = new Insets(4, 5, 4, 5); + gbc.gridy++; + } + + // ===== Shared UI Helpers ===== + + private static JTextArea createHelpArea() { + JTextArea area = new JTextArea(); + area.setLineWrap(true); + area.setWrapStyleWord(true); + area.setEditable(false); + area.setFocusable(false); + area.setOpaque(false); + area.setBorder(null); + area.setFont(UIManager.getFont("Label.font").deriveFont((float) UIManager.getFont("Label.font").getSize() - 1)); + area.setForeground(UIManager.getColor("Label.disabledForeground")); + return area; + } + + // ===== State Management ===== + + public boolean isModified() { + return isCliToolsModified() || isAcpToolsModified(); + } + + private boolean isCliToolsModified() { + List saved = stateService.getCliTools(); + List current = cliToolTableModel.getAllTools(); + if (saved == null) return !current.isEmpty(); + return !saved.equals(current); + } + + private boolean isAcpToolsModified() { + List saved = stateService.getAcpTools(); + List current = acpToolTableModel.getAllTools(); + if (saved == null) return !current.isEmpty(); + return !saved.equals(current); + } + + public void apply() { + stateService.setCliTools(new ArrayList<>(cliToolTableModel.getAllTools())); + stateService.setAcpTools(new ArrayList<>(acpToolTableModel.getAllTools())); + } + + public void reset() { + DevoxxGenieStateService state = DevoxxGenieStateService.getInstance(); + + cliToolTableModel.clear(); + List tools = state.getCliTools(); + if (tools != null) { + for (CliToolConfig tool : tools) { + cliToolTableModel.addTool(tool); + } + } + + acpToolTableModel.clear(); + List acpTools = state.getAcpTools(); + if (acpTools != null) { + for (AcpToolConfig tool : acpTools) { + acpToolTableModel.addTool(tool); + } + } + } + + @Override + public void addListeners() { + // No dynamic listeners needed + } + + // ===== CLI Tool Table Model ===== + + private static class CliToolTableModel extends AbstractTableModel { + private static final String[] COLUMNS = {"Enabled", "Type", "Executable Path", "MCP Config Flag"}; + private final List tools = new ArrayList<>(); + + @Override + public int getRowCount() { + return tools.size(); + } + + @Override + public int getColumnCount() { + return COLUMNS.length; + } + + @Override + public String getColumnName(int column) { + return COLUMNS[column]; + } + + @Override + public Class getColumnClass(int columnIndex) { + return columnIndex == 0 ? Boolean.class : String.class; + } + + @Override + public boolean isCellEditable(int rowIndex, int columnIndex) { + return columnIndex == 0; // Only "Enabled" is directly editable + } + + @Override + public Object getValueAt(int rowIndex, int columnIndex) { + CliToolConfig tool = tools.get(rowIndex); + return switch (columnIndex) { + case 0 -> tool.isEnabled(); + case 1 -> tool.getType() != null ? tool.getType().getDisplayName() : ""; + case 2 -> tool.getExecutablePath(); + case 3 -> tool.getMcpConfigFlag() != null ? tool.getMcpConfigFlag() : ""; + default -> ""; + }; + } + + @Override + public void setValueAt(Object value, int rowIndex, int columnIndex) { + if (columnIndex == 0 && value instanceof Boolean) { + tools.get(rowIndex).setEnabled((Boolean) value); + fireTableCellUpdated(rowIndex, columnIndex); + } + } + + public void addTool(CliToolConfig tool) { + tools.add(tool); + fireTableRowsInserted(tools.size() - 1, tools.size() - 1); + } + + public void updateTool(int row, CliToolConfig tool) { + tools.set(row, tool); + fireTableRowsUpdated(row, row); + } + + public void removeTool(int row) { + tools.remove(row); + fireTableRowsDeleted(row, row); + } + + public CliToolConfig getToolAt(int row) { + return tools.get(row); + } + + public List getAllTools() { + return new ArrayList<>(tools); + } + + public void clear() { + int size = tools.size(); + if (size > 0) { + tools.clear(); + fireTableRowsDeleted(0, size - 1); + } + } + } + + // ===== CLI Tool Dialog ===== + + private static class CliToolDialog extends com.intellij.openapi.ui.DialogWrapper { + private final JComboBox typeCombo = new JComboBox<>(CliToolConfig.CliType.values()); + private final JBTextField pathField = new JBTextField(); + private final JBTextField argsField = new JBTextField(); + private final JBTextField envVarsField = new JBTextField(); + private final JBTextField mcpConfigFlagField = new JBTextField(); + private final JBCheckBox enabledCheckbox = new JBCheckBox("Enabled", true); + private final JBLabel testResultLabel = new JBLabel(); + private final JTextArea pathHelpArea = createHelpArea(); + private boolean suppressTypeListener = false; + + CliToolDialog(CliToolConfig existing) { + super(true); + setTitle(existing == null ? "Add CLI Tool" : "Edit CLI Tool"); + if (existing != null) { + suppressTypeListener = true; + typeCombo.setSelectedItem(existing.getType() != null ? existing.getType() : CliToolConfig.CliType.CUSTOM); + suppressTypeListener = false; + pathField.setText(existing.getExecutablePath()); + argsField.setText(existing.getExtraArgs() != null ? String.join(" ", existing.getExtraArgs()) : ""); + envVarsField.setText(formatEnvVars(existing.getEnvVars())); + mcpConfigFlagField.setText(existing.getMcpConfigFlag() != null ? existing.getMcpConfigFlag() : ""); + enabledCheckbox.setSelected(existing.isEnabled()); + updatePathHelp(existing.getType()); + } + init(); + // Pre-fill defaults for the initially selected type when adding a new entry + if (existing == null) { + onTypeChanged(); + } + } + + @Override + protected JComponent createCenterPanel() { + JPanel panel = new JPanel(new GridBagLayout()); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.insets = new Insets(4, 4, 4, 4); + gbc.anchor = GridBagConstraints.WEST; + + // Type selector (first row) + gbc.gridy = 0; + gbc.gridx = 0; + gbc.weightx = 0; + panel.add(new JBLabel("Type:"), gbc); + gbc.gridx = 1; + gbc.weightx = 1.0; + typeCombo.setRenderer(new DefaultListCellRenderer() { + @Override + public java.awt.Component getListCellRendererComponent(JList list, Object value, int index, boolean sel, boolean focus) { + super.getListCellRendererComponent(list, value, index, sel, focus); + if (value instanceof CliToolConfig.CliType t) setText(t.getDisplayName()); + return this; + } + }); + typeCombo.addActionListener(e -> onTypeChanged()); + panel.add(typeCombo, gbc); + + gbc.gridy++; + gbc.gridx = 0; + gbc.weightx = 0; + panel.add(new JBLabel("Executable path:"), gbc); + gbc.gridx = 1; + gbc.weightx = 1.0; + panel.add(pathField, gbc); + + // Dynamic help text below executable path + gbc.gridy++; + gbc.gridx = 1; + gbc.weightx = 1.0; + panel.add(pathHelpArea, gbc); + + gbc.gridy++; + gbc.gridx = 0; + gbc.weightx = 0; + panel.add(new JBLabel("Extra args:"), gbc); + gbc.gridx = 1; + gbc.weightx = 1.0; + panel.add(argsField, gbc); + + gbc.gridy++; + gbc.gridx = 0; + gbc.weightx = 0; + panel.add(new JBLabel("Env vars:"), gbc); + gbc.gridx = 1; + gbc.weightx = 1.0; + envVarsField.getEmptyText().setText("KEY=VALUE, KEY2=VALUE2"); + panel.add(envVarsField, gbc); + + gbc.gridy++; + gbc.gridx = 0; + gbc.weightx = 0; + panel.add(new JBLabel("MCP config flag:"), gbc); + gbc.gridx = 1; + gbc.weightx = 1.0; + mcpConfigFlagField.setEditable(false); + mcpConfigFlagField.setEnabled(false); + panel.add(mcpConfigFlagField, gbc); + + // Help text for MCP config + gbc.gridy++; + gbc.gridx = 1; + gbc.weightx = 1.0; + JBLabel mcpHelpLabel = new JBLabel("Backlog MCP config is auto-generated and passed to the CLI tool"); + mcpHelpLabel.setForeground(UIManager.getColor("Label.disabledForeground")); + mcpHelpLabel.setFont(mcpHelpLabel.getFont().deriveFont((float) mcpHelpLabel.getFont().getSize() - 1)); + panel.add(mcpHelpLabel, gbc); + + gbc.gridy++; + gbc.gridx = 0; + gbc.gridwidth = 2; + panel.add(enabledCheckbox, gbc); + + // Test button row + gbc.gridy++; + gbc.gridx = 0; + gbc.gridwidth = 2; + JPanel testRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 5, 0)); + JButton testButton = new JButton("Test Connection"); + testButton.addActionListener(e -> runTest()); + testRow.add(testButton); + testResultLabel.setForeground(UIManager.getColor("Label.disabledForeground")); + testRow.add(testResultLabel); + panel.add(testRow, gbc); + + return panel; + } + + private void onTypeChanged() { + if (suppressTypeListener) return; + CliToolConfig.CliType type = (CliToolConfig.CliType) typeCombo.getSelectedItem(); + if (type == null) return; + updatePathHelp(type); + if (type == CliToolConfig.CliType.CUSTOM) return; + + // Delegate to the Command for this type — no switch needed + com.devoxx.genie.service.cli.command.CliCommand command = type.createCommand(); + pathField.setText(command.defaultExecutablePath()); + argsField.setText(command.defaultExtraArgs()); + mcpConfigFlagField.setText(command.defaultMcpConfigFlag()); + } + + private void updatePathHelp(CliToolConfig.CliType type) { + if (type == null || type == CliToolConfig.CliType.CUSTOM) { + pathHelpArea.setText("Find the full path by running: whereis "); + } else { + com.devoxx.genie.service.cli.command.CliCommand cmd = type.createCommand(); + pathHelpArea.setText("Find the full path by running: whereis " + cmd.defaultExecutablePath()); + } + } + + private void runTest() { + String path = pathField.getText().trim(); + if (path.isEmpty()) { + testResultLabel.setForeground(UIManager.getColor("Component.errorFocusColor")); + testResultLabel.setText("Executable path is empty"); + return; + } + + testResultLabel.setForeground(UIManager.getColor("Label.disabledForeground")); + testResultLabel.setText("Testing..."); + + com.intellij.openapi.application.ApplicationManager.getApplication().executeOnPooledThread(() -> { + try { + // Use the CliCommand abstraction to build command and deliver prompt + // This ensures tool-specific behavior (e.g., --prompt flag for Kimi) + CliToolConfig testConfig = getResult(); + String testPrompt = "Respond with only: OK"; + CliToolConfig.CliType cliType = testConfig.getType() != null + ? testConfig.getType() : CliToolConfig.CliType.CUSTOM; + com.devoxx.genie.service.cli.command.CliCommand cliCommand = cliType.createCommand(); + java.util.List command = cliCommand.buildProcessCommand(testConfig, testPrompt, null); + + ProcessBuilder pb = new ProcessBuilder(command); + pb.redirectErrorStream(false); + + // Inherit the user's shell environment (PATH, tokens, etc.) + pb.environment().putAll(com.intellij.util.EnvironmentUtil.getEnvironmentMap()); + + // Overlay with tool-specific env var overrides + if (testConfig.getEnvVars() != null && !testConfig.getEnvVars().isEmpty()) { + pb.environment().putAll(testConfig.getEnvVars()); + } + + Process process = pb.start(); + + // Delegate prompt delivery to the command + cliCommand.writePrompt(process, testPrompt); + + // Read stdout and stderr in parallel + StringBuilder stdout = new StringBuilder(); + StringBuilder stderr = new StringBuilder(); + + Thread stdoutReader = new Thread(() -> { + try (java.io.BufferedReader reader = new java.io.BufferedReader( + new java.io.InputStreamReader(process.getInputStream()))) { + String line; + int count = 0; + while ((line = reader.readLine()) != null && count < 10) { + if (stdout.length() > 0) stdout.append(" "); + stdout.append(line); + count++; + } + } catch (java.io.IOException ignored) {} + }); + Thread stderrReader = new Thread(() -> { + try (java.io.BufferedReader reader = new java.io.BufferedReader( + new java.io.InputStreamReader(process.getErrorStream()))) { + String line; + int count = 0; + while ((line = reader.readLine()) != null && count < 10) { + if (stderr.length() > 0) stderr.append(" "); + stderr.append(line); + count++; + } + } catch (java.io.IOException ignored) {} + }); + + stdoutReader.setDaemon(true); + stderrReader.setDaemon(true); + stdoutReader.start(); + stderrReader.start(); + + boolean exited = process.waitFor(30, java.util.concurrent.TimeUnit.SECONDS); + if (!exited) { + process.destroyForcibly(); + showTestResult(false, "Timed out after 30s"); + return; + } + + stdoutReader.join(3000); + stderrReader.join(3000); + + int exitCode = process.exitValue(); + if (exitCode == 0) { + showTestResult(true, "Connected successfully"); + } else { + // Prefer stderr for error messages, fall back to stdout + String err = stderr.toString().trim(); + if (err.isEmpty()) err = stdout.toString().trim(); + if (err.isEmpty()) err = "Exit code " + exitCode; + showTestResult(false, err); + } + + } catch (java.io.IOException ex) { + showTestResult(false, "Not found: " + ex.getMessage()); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + showTestResult(false, "Interrupted"); + } + }); + } + + private void showTestResult(boolean success, String message) { + SwingUtilities.invokeLater(() -> { + String truncated = message.length() > 80 ? message.substring(0, 80) + "..." : message; + if (success) { + testResultLabel.setForeground(new java.awt.Color(0, 128, 0)); + testResultLabel.setText(truncated); + } else { + testResultLabel.setForeground(UIManager.getColor("Component.errorFocusColor")); + testResultLabel.setText(truncated); + } + }); + } + + private static String formatEnvVars(java.util.Map envVars) { + if (envVars == null || envVars.isEmpty()) return ""; + StringBuilder sb = new StringBuilder(); + for (var entry : envVars.entrySet()) { + if (sb.length() > 0) sb.append(", "); + sb.append(entry.getKey()).append("=").append(entry.getValue()); + } + return sb.toString(); + } + + /** + * Strip surrounding shell-style quotes from an argument. + * ProcessBuilder doesn't use a shell, so literal quotes must be removed. + */ + private static String stripShellQuotes(String arg) { + if (arg.length() >= 2) { + if ((arg.startsWith("'") && arg.endsWith("'")) || + (arg.startsWith("\"") && arg.endsWith("\""))) { + return arg.substring(1, arg.length() - 1); + } + } + return arg; + } + + private static java.util.Map parseEnvVars(String text) { + java.util.Map map = new java.util.LinkedHashMap<>(); + if (text == null || text.isEmpty()) return map; + for (String pair : text.split(",")) { + String trimmed = pair.trim(); + int eq = trimmed.indexOf('='); + if (eq > 0 && eq < trimmed.length() - 1) { + map.put(trimmed.substring(0, eq).trim(), trimmed.substring(eq + 1).trim()); + } + } + return map; + } + + public CliToolConfig getResult() { + List args = new ArrayList<>(); + String argsText = argsField.getText().trim(); + if (!argsText.isEmpty()) { + for (String arg : argsText.split("\\s+")) { + args.add(stripShellQuotes(arg)); + } + } + CliToolConfig.CliType selectedType = (CliToolConfig.CliType) typeCombo.getSelectedItem(); + if (selectedType == null) selectedType = CliToolConfig.CliType.CUSTOM; + return CliToolConfig.builder() + .type(selectedType) + .name(selectedType.getDisplayName()) + .executablePath(pathField.getText().trim()) + .extraArgs(args) + .envVars(parseEnvVars(envVarsField.getText().trim())) + .mcpConfigFlag(mcpConfigFlagField.getText().trim()) + .enabled(enabledCheckbox.isSelected()) + .build(); + } + } + + // ===== ACP Tool Table Model ===== + + private static class AcpToolTableModel extends AbstractTableModel { + private static final String[] COLUMNS = {"Enabled", "Type", "Executable Path"}; + private final List tools = new ArrayList<>(); + + @Override + public int getRowCount() { + return tools.size(); + } + + @Override + public int getColumnCount() { + return COLUMNS.length; + } + + @Override + public String getColumnName(int column) { + return COLUMNS[column]; + } + + @Override + public Class getColumnClass(int columnIndex) { + return columnIndex == 0 ? Boolean.class : String.class; + } + + @Override + public boolean isCellEditable(int rowIndex, int columnIndex) { + return columnIndex == 0; + } + + @Override + public Object getValueAt(int rowIndex, int columnIndex) { + AcpToolConfig tool = tools.get(rowIndex); + return switch (columnIndex) { + case 0 -> tool.isEnabled(); + case 1 -> tool.getType() != null ? tool.getType().getDisplayName() : ""; + case 2 -> tool.getExecutablePath(); + default -> ""; + }; + } + + @Override + public void setValueAt(Object value, int rowIndex, int columnIndex) { + if (columnIndex == 0 && value instanceof Boolean) { + tools.get(rowIndex).setEnabled((Boolean) value); + fireTableCellUpdated(rowIndex, columnIndex); + } + } + + public void addTool(AcpToolConfig tool) { + tools.add(tool); + fireTableRowsInserted(tools.size() - 1, tools.size() - 1); + } + + public void updateTool(int row, AcpToolConfig tool) { + tools.set(row, tool); + fireTableRowsUpdated(row, row); + } + + public void removeTool(int row) { + tools.remove(row); + fireTableRowsDeleted(row, row); + } + + public AcpToolConfig getToolAt(int row) { + return tools.get(row); + } + + public List getAllTools() { + return new ArrayList<>(tools); + } + + public void clear() { + int size = tools.size(); + if (size > 0) { + tools.clear(); + fireTableRowsDeleted(0, size - 1); + } + } + } + + // ===== ACP Tool Dialog ===== + + private static class AcpToolDialog extends com.intellij.openapi.ui.DialogWrapper { + private final JComboBox typeCombo = new JComboBox<>(AcpToolConfig.AcpType.values()); + private final JBTextField pathField = new JBTextField(); + private final JBCheckBox enabledCheckbox = new JBCheckBox("Enabled", true); + private final JBLabel testResultLabel = new JBLabel(); + private final JTextArea pathHelpArea = createHelpArea(); + private boolean suppressTypeListener = false; + + AcpToolDialog(AcpToolConfig existing) { + super(true); + setTitle(existing == null ? "Add ACP Tool" : "Edit ACP Tool"); + if (existing != null) { + suppressTypeListener = true; + typeCombo.setSelectedItem(existing.getType() != null ? existing.getType() : AcpToolConfig.AcpType.CUSTOM); + suppressTypeListener = false; + pathField.setText(existing.getExecutablePath()); + enabledCheckbox.setSelected(existing.isEnabled()); + updatePathHelp(existing.getType()); + } + init(); + if (existing == null) { + onTypeChanged(); + } + } + + @Override + protected JComponent createCenterPanel() { + JPanel panel = new JPanel(new GridBagLayout()); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.insets = new Insets(4, 4, 4, 4); + gbc.anchor = GridBagConstraints.WEST; + + gbc.gridy = 0; + gbc.gridx = 0; + gbc.weightx = 0; + panel.add(new JBLabel("Type:"), gbc); + gbc.gridx = 1; + gbc.weightx = 1.0; + typeCombo.setRenderer(new DefaultListCellRenderer() { + @Override + public java.awt.Component getListCellRendererComponent(JList list, Object value, int index, boolean sel, boolean focus) { + super.getListCellRendererComponent(list, value, index, sel, focus); + if (value instanceof AcpToolConfig.AcpType t) setText(t.getDisplayName()); + return this; + } + }); + typeCombo.addActionListener(e -> onTypeChanged()); + panel.add(typeCombo, gbc); + + gbc.gridy++; + gbc.gridx = 0; + gbc.weightx = 0; + panel.add(new JBLabel("Executable path:"), gbc); + gbc.gridx = 1; + gbc.weightx = 1.0; + panel.add(pathField, gbc); + + // Dynamic help text below executable path + gbc.gridy++; + gbc.gridx = 1; + gbc.weightx = 1.0; + panel.add(pathHelpArea, gbc); + + gbc.gridy++; + gbc.gridx = 0; + gbc.gridwidth = 2; + panel.add(enabledCheckbox, gbc); + + // Test button row + gbc.gridy++; + gbc.gridx = 0; + gbc.gridwidth = 2; + JPanel testRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 5, 0)); + JButton testButton = new JButton("Test Connection"); + testButton.addActionListener(e -> runTest()); + testRow.add(testButton); + testResultLabel.setForeground(UIManager.getColor("Label.disabledForeground")); + testRow.add(testResultLabel); + panel.add(testRow, gbc); + + return panel; + } + + private void onTypeChanged() { + if (suppressTypeListener) return; + AcpToolConfig.AcpType type = (AcpToolConfig.AcpType) typeCombo.getSelectedItem(); + updatePathHelp(type); + if (type == null || type == AcpToolConfig.AcpType.CUSTOM) return; + pathField.setText(type.getDefaultExecutablePath()); + } + + private void updatePathHelp(AcpToolConfig.AcpType type) { + if (type == null || type == AcpToolConfig.AcpType.CUSTOM) { + pathHelpArea.setText("Find the full path by running: whereis "); + } else if (type == AcpToolConfig.AcpType.CLAUDE) { + pathHelpArea.setText("Requires the ACP bridge: https://github.com/zed-industries/claude-code-acp\n" + + "Find the full path by running: whereis claude-code-acp"); + } else { + pathHelpArea.setText("Find the full path by running: whereis " + type.getDefaultExecutablePath()); + } + } + + private void runTest() { + String path = pathField.getText().trim(); + if (path.isEmpty()) { + testResultLabel.setForeground(UIManager.getColor("Component.errorFocusColor")); + testResultLabel.setText("Executable path is empty"); + return; + } + + testResultLabel.setForeground(UIManager.getColor("Label.disabledForeground")); + testResultLabel.setText("Testing ACP handshake..."); + + com.intellij.openapi.application.ApplicationManager.getApplication().executeOnPooledThread(() -> { + try { + com.devoxx.genie.service.acp.protocol.AcpClient client = + new com.devoxx.genie.service.acp.protocol.AcpClient(text -> {}); + AcpToolConfig.AcpType selType = (AcpToolConfig.AcpType) typeCombo.getSelectedItem(); + String flag = (selType != null) ? selType.getDefaultAcpFlag() : "acp"; + client.start(null, path, flag); + client.initialize(); + client.close(); + showTestResult(true, "ACP handshake successful"); + } catch (Exception ex) { + showTestResult(false, ex.getMessage()); + } + }); + } + + private void showTestResult(boolean success, String message) { + SwingUtilities.invokeLater(() -> { + String truncated = message.length() > 80 ? message.substring(0, 80) + "..." : message; + if (success) { + testResultLabel.setForeground(new java.awt.Color(0, 128, 0)); + testResultLabel.setText(truncated); + } else { + testResultLabel.setForeground(UIManager.getColor("Component.errorFocusColor")); + testResultLabel.setText(truncated); + } + }); + } + + public AcpToolConfig getResult() { + AcpToolConfig.AcpType selectedType = (AcpToolConfig.AcpType) typeCombo.getSelectedItem(); + if (selectedType == null) selectedType = AcpToolConfig.AcpType.CUSTOM; + return AcpToolConfig.builder() + .type(selectedType) + .name(selectedType.getDisplayName()) + .executablePath(pathField.getText().trim()) + .acpFlag(selectedType.getDefaultAcpFlag()) + .enabled(enabledCheckbox.isSelected()) + .build(); + } + } +} diff --git a/src/main/java/com/devoxx/genie/ui/settings/runner/RunnerSettingsConfigurable.java b/src/main/java/com/devoxx/genie/ui/settings/runner/RunnerSettingsConfigurable.java new file mode 100644 index 00000000..6ca2e791 --- /dev/null +++ b/src/main/java/com/devoxx/genie/ui/settings/runner/RunnerSettingsConfigurable.java @@ -0,0 +1,65 @@ +package com.devoxx.genie.ui.settings.runner; + +import com.devoxx.genie.ui.topic.AppTopics; +import com.intellij.openapi.options.Configurable; +import com.intellij.openapi.project.Project; +import com.intellij.util.messages.MessageBus; +import org.jetbrains.annotations.Nls; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import javax.swing.*; + +/** + * Configurable for CLI/ACP Runners settings. + * Registered in plugin.xml under the DevoxxGenie parent settings group. + */ +public class RunnerSettingsConfigurable implements Configurable { + + private final Project project; + private final MessageBus messageBus; + private RunnerSettingsComponent runnerSettingsComponent; + + public RunnerSettingsConfigurable(@NotNull Project project) { + this.project = project; + this.messageBus = project.getMessageBus(); + } + + @Nls(capitalization = Nls.Capitalization.Title) + @Override + public String getDisplayName() { + return "CLI/ACP Runners"; + } + + @Nullable + @Override + public JComponent createComponent() { + runnerSettingsComponent = new RunnerSettingsComponent(project); + return runnerSettingsComponent.createPanel(); + } + + @Override + public boolean isModified() { + return runnerSettingsComponent != null && runnerSettingsComponent.isModified(); + } + + @Override + public void apply() { + if (runnerSettingsComponent != null) { + runnerSettingsComponent.apply(); + messageBus.syncPublisher(AppTopics.SETTINGS_CHANGED_TOPIC).settingsChanged(true); + } + } + + @Override + public void reset() { + if (runnerSettingsComponent != null) { + runnerSettingsComponent.reset(); + } + } + + @Override + public void disposeUIResources() { + runnerSettingsComponent = null; + } +} diff --git a/src/main/java/com/devoxx/genie/ui/settings/spec/SpecSettingsComponent.java b/src/main/java/com/devoxx/genie/ui/settings/spec/SpecSettingsComponent.java index 66a21cb9..2da11d70 100644 --- a/src/main/java/com/devoxx/genie/ui/settings/spec/SpecSettingsComponent.java +++ b/src/main/java/com/devoxx/genie/ui/settings/spec/SpecSettingsComponent.java @@ -1,7 +1,5 @@ package com.devoxx.genie.ui.settings.spec; -import com.devoxx.genie.model.spec.AcpToolConfig; -import com.devoxx.genie.model.spec.CliToolConfig; import com.devoxx.genie.service.spec.BacklogConfigService; import com.devoxx.genie.service.spec.SpecService; import com.devoxx.genie.ui.settings.AbstractSettingsComponent; @@ -13,18 +11,13 @@ import com.intellij.openapi.project.Project; import com.intellij.openapi.vfs.LocalFileSystem; import com.intellij.openapi.vfs.VirtualFile; -import com.intellij.ui.ToolbarDecorator; import com.intellij.ui.components.JBCheckBox; import com.intellij.ui.components.JBLabel; import com.intellij.ui.components.JBTextField; -import com.intellij.ui.table.JBTable; import org.jetbrains.annotations.NotNull; import javax.swing.*; -import javax.swing.table.AbstractTableModel; import java.awt.*; -import java.util.ArrayList; -import java.util.List; import java.util.Objects; /** @@ -48,12 +41,6 @@ public class SpecSettingsComponent extends AbstractSettingsComponent { stateService.getSpecTaskRunnerTimeoutMinutes() != null ? stateService.getSpecTaskRunnerTimeoutMinutes() : 10, 1, 60, 1)); - private final CliToolTableModel cliToolTableModel = new CliToolTableModel(); - private final JBTable cliToolTable = new JBTable(cliToolTableModel); - - private final AcpToolTableModel acpToolTableModel = new AcpToolTableModel(); - private final JBTable acpToolTable = new JBTable(acpToolTableModel); - public SpecSettingsComponent(@NotNull Project project) { this.project = project; JPanel contentPanel = new JPanel(new GridBagLayout()); @@ -120,75 +107,6 @@ public SpecSettingsComponent(@NotNull Project project) { "to complete before being automatically skipped. The timeout resets if the agent " + "starts working on the task (status changes to 'In Progress')."); - // --- CLI Runners --- - addSection(contentPanel, gbc, "CLI Runners"); - - addHelpText(contentPanel, gbc, - "Configure external CLI tools (e.g., GitHub Copilot CLI, Claude Code, Gemini CLI) " + - "that can execute spec tasks instead of the built-in LLM provider. " + - "CLI tools must have the Backlog MCP server installed so they can update task status. " + - "Select the execution mode in the Spec Browser toolbar."); - - // Configure table columns - cliToolTable.getColumnModel().getColumn(0).setMaxWidth(60); // Enabled - cliToolTable.getColumnModel().getColumn(0).setMinWidth(60); - cliToolTable.getColumnModel().getColumn(1).setPreferredWidth(80); // Type - cliToolTable.getColumnModel().getColumn(2).setPreferredWidth(220); // Path - cliToolTable.getColumnModel().getColumn(3).setPreferredWidth(170); // MCP Config Flag - cliToolTable.setRowHeight(25); - - ToolbarDecorator decorator = ToolbarDecorator.createDecorator(cliToolTable) - .setAddAction(button -> addCliTool()) - .setEditAction(button -> editCliTool()) - .setRemoveAction(button -> removeCliTool()); - - JPanel tablePanel = decorator.createPanel(); - tablePanel.setPreferredSize(new Dimension(-1, 150)); - - gbc.gridwidth = 2; - gbc.gridx = 0; - gbc.fill = GridBagConstraints.BOTH; - contentPanel.add(tablePanel, gbc); - gbc.fill = GridBagConstraints.HORIZONTAL; - gbc.gridy++; - - // Load existing CLI tools from state - loadCliTools(); - - // --- ACP Runners --- - addSection(contentPanel, gbc, "ACP Runners"); - - addHelpText(contentPanel, gbc, - "Configure external ACP (Agent Communication Protocol) tools that communicate " + - "via JSON-RPC 2.0 over stdin/stdout. ACP provides structured streaming, file operations, " + - "terminal management, and capability negotiation. " + - "Supported CLIs: Kimi, Gemini CLI, Kilocode."); - - // Configure ACP table columns - acpToolTable.getColumnModel().getColumn(0).setMaxWidth(60); // Enabled - acpToolTable.getColumnModel().getColumn(0).setMinWidth(60); - acpToolTable.getColumnModel().getColumn(1).setPreferredWidth(100); // Type - acpToolTable.getColumnModel().getColumn(2).setPreferredWidth(270); // Path - acpToolTable.setRowHeight(25); - - ToolbarDecorator acpDecorator = ToolbarDecorator.createDecorator(acpToolTable) - .setAddAction(button -> addAcpTool()) - .setEditAction(button -> editAcpTool()) - .setRemoveAction(button -> removeAcpTool()); - - JPanel acpTablePanel = acpDecorator.createPanel(); - acpTablePanel.setPreferredSize(new Dimension(-1, 150)); - - gbc.gridwidth = 2; - gbc.gridx = 0; - gbc.fill = GridBagConstraints.BOTH; - contentPanel.add(acpTablePanel, gbc); - gbc.fill = GridBagConstraints.HORIZONTAL; - gbc.gridy++; - - // Load existing ACP tools from state - loadAcpTools(); - // Filler gbc.weighty = 1.0; gbc.gridy++; @@ -197,93 +115,7 @@ public SpecSettingsComponent(@NotNull Project project) { panel.add(contentPanel, BorderLayout.NORTH); } - // ===== CLI Tool Table Management ===== - - private void loadCliTools() { - List tools = stateService.getCliTools(); - if (tools != null) { - for (CliToolConfig tool : tools) { - cliToolTableModel.addTool(tool); - } - } - // Pre-populate with Copilot if no tools configured - if (cliToolTableModel.getRowCount() == 0) { - com.devoxx.genie.service.cli.command.CliCommand cmd = CliToolConfig.CliType.COPILOT.createCommand(); - List defaultArgs = new ArrayList<>(); - for (String arg : cmd.defaultExtraArgs().split("\\s+")) { - if (!arg.isEmpty()) defaultArgs.add(arg); - } - cliToolTableModel.addTool(CliToolConfig.builder() - .type(CliToolConfig.CliType.COPILOT) - .name(CliToolConfig.CliType.COPILOT.getDisplayName()) - .executablePath(cmd.defaultExecutablePath()) - .extraArgs(defaultArgs) - .mcpConfigFlag(cmd.defaultMcpConfigFlag()) - .enabled(true) - .build()); - } - } - - private void addCliTool() { - CliToolDialog dialog = new CliToolDialog(null); - if (dialog.showAndGet()) { - cliToolTableModel.addTool(dialog.getResult()); - } - } - - private void editCliTool() { - int row = cliToolTable.getSelectedRow(); - if (row < 0) return; - CliToolConfig existing = cliToolTableModel.getToolAt(row); - CliToolDialog dialog = new CliToolDialog(existing); - if (dialog.showAndGet()) { - cliToolTableModel.updateTool(row, dialog.getResult()); - } - } - - private void removeCliTool() { - int row = cliToolTable.getSelectedRow(); - if (row >= 0) { - cliToolTableModel.removeTool(row); - } - } - - // ===== ACP Tool Table Management ===== - - private void loadAcpTools() { - List tools = stateService.getAcpTools(); - if (tools != null) { - for (AcpToolConfig tool : tools) { - acpToolTableModel.addTool(tool); - } - } - } - - private void addAcpTool() { - AcpToolDialog dialog = new AcpToolDialog(null); - if (dialog.showAndGet()) { - acpToolTableModel.addTool(dialog.getResult()); - } - } - - private void editAcpTool() { - int row = acpToolTable.getSelectedRow(); - if (row < 0) return; - AcpToolConfig existing = acpToolTableModel.getToolAt(row); - AcpToolDialog dialog = new AcpToolDialog(existing); - if (dialog.showAndGet()) { - acpToolTableModel.updateTool(row, dialog.getResult()); - } - } - - private void removeAcpTool() { - int row = acpToolTable.getSelectedRow(); - if (row >= 0) { - acpToolTableModel.removeTool(row); - } - } - - // ===== Existing Helper Methods ===== + // ===== Helper Methods ===== private void addFullWidthRow(JPanel panel, GridBagConstraints gbc, JComponent component) { gbc.gridwidth = 2; @@ -310,24 +142,6 @@ private void addHelpText(JPanel panel, GridBagConstraints gbc, String text) { gbc.gridy++; } - private void addCodeBlock(JPanel panel, GridBagConstraints gbc, String code) { - gbc.gridwidth = 2; - gbc.gridx = 0; - gbc.insets = new Insets(4, 25, 8, 5); - JTextArea codeArea = new JTextArea(code); - codeArea.setLineWrap(false); - codeArea.setEditable(false); - codeArea.setFocusable(true); - codeArea.setFont(new Font(Font.MONOSPACED, Font.PLAIN, UIManager.getFont("Label.font").getSize())); - codeArea.setBackground(UIManager.getColor("EditorPane.background")); - codeArea.setBorder(BorderFactory.createCompoundBorder( - BorderFactory.createLineBorder(UIManager.getColor("Component.borderColor"), 1), - BorderFactory.createEmptyBorder(8, 10, 8, 10))); - panel.add(codeArea, gbc); - gbc.insets = new Insets(4, 5, 4, 5); - gbc.gridy++; - } - private void updateInitButtonState() { boolean initialized = BacklogConfigService.getInstance(project).isBacklogInitialized(); initBacklogButton.setEnabled(!initialized); @@ -406,31 +220,13 @@ public boolean isModified() { DevoxxGenieStateService state = DevoxxGenieStateService.getInstance(); return enableSpecBrowserCheckbox.isSelected() != Boolean.TRUE.equals(state.getSpecBrowserEnabled()) || !Objects.equals(specDirectoryField.getText().trim(), state.getSpecDirectory()) - || !Objects.equals(taskRunnerTimeoutSpinner.getValue(), state.getSpecTaskRunnerTimeoutMinutes()) - || isCliToolsModified() - || isAcpToolsModified(); - } - - private boolean isCliToolsModified() { - List saved = stateService.getCliTools(); - List current = cliToolTableModel.getAllTools(); - if (saved == null) return !current.isEmpty(); - return !saved.equals(current); - } - - private boolean isAcpToolsModified() { - List saved = stateService.getAcpTools(); - List current = acpToolTableModel.getAllTools(); - if (saved == null) return !current.isEmpty(); - return !saved.equals(current); + || !Objects.equals(taskRunnerTimeoutSpinner.getValue(), state.getSpecTaskRunnerTimeoutMinutes()); } public void apply() { stateService.setSpecBrowserEnabled(enableSpecBrowserCheckbox.isSelected()); stateService.setSpecDirectory(specDirectoryField.getText().trim()); stateService.setSpecTaskRunnerTimeoutMinutes((Integer) taskRunnerTimeoutSpinner.getValue()); - stateService.setCliTools(new ArrayList<>(cliToolTableModel.getAllTools())); - stateService.setAcpTools(new ArrayList<>(acpToolTableModel.getAllTools())); } public void reset() { @@ -438,634 +234,10 @@ public void reset() { enableSpecBrowserCheckbox.setSelected(Boolean.TRUE.equals(state.getSpecBrowserEnabled())); specDirectoryField.setText(state.getSpecDirectory() != null ? state.getSpecDirectory() : "backlog"); taskRunnerTimeoutSpinner.setValue(state.getSpecTaskRunnerTimeoutMinutes() != null ? state.getSpecTaskRunnerTimeoutMinutes() : 10); - - cliToolTableModel.clear(); - List tools = state.getCliTools(); - if (tools != null) { - for (CliToolConfig tool : tools) { - cliToolTableModel.addTool(tool); - } - } - - acpToolTableModel.clear(); - List acpTools = state.getAcpTools(); - if (acpTools != null) { - for (AcpToolConfig tool : acpTools) { - acpToolTableModel.addTool(tool); - } - } } @Override public void addListeners() { // No dynamic listeners needed } - - // ===== CLI Tool Table Model ===== - - private static class CliToolTableModel extends AbstractTableModel { - private static final String[] COLUMNS = {"Enabled", "Type", "Executable Path", "MCP Config Flag"}; - private final List tools = new ArrayList<>(); - - @Override - public int getRowCount() { - return tools.size(); - } - - @Override - public int getColumnCount() { - return COLUMNS.length; - } - - @Override - public String getColumnName(int column) { - return COLUMNS[column]; - } - - @Override - public Class getColumnClass(int columnIndex) { - return columnIndex == 0 ? Boolean.class : String.class; - } - - @Override - public boolean isCellEditable(int rowIndex, int columnIndex) { - return columnIndex == 0; // Only "Enabled" is directly editable - } - - @Override - public Object getValueAt(int rowIndex, int columnIndex) { - CliToolConfig tool = tools.get(rowIndex); - return switch (columnIndex) { - case 0 -> tool.isEnabled(); - case 1 -> tool.getType() != null ? tool.getType().getDisplayName() : ""; - case 2 -> tool.getExecutablePath(); - case 3 -> tool.getMcpConfigFlag() != null ? tool.getMcpConfigFlag() : ""; - default -> ""; - }; - } - - @Override - public void setValueAt(Object value, int rowIndex, int columnIndex) { - if (columnIndex == 0 && value instanceof Boolean) { - tools.get(rowIndex).setEnabled((Boolean) value); - fireTableCellUpdated(rowIndex, columnIndex); - } - } - - public void addTool(CliToolConfig tool) { - tools.add(tool); - fireTableRowsInserted(tools.size() - 1, tools.size() - 1); - } - - public void updateTool(int row, CliToolConfig tool) { - tools.set(row, tool); - fireTableRowsUpdated(row, row); - } - - public void removeTool(int row) { - tools.remove(row); - fireTableRowsDeleted(row, row); - } - - public CliToolConfig getToolAt(int row) { - return tools.get(row); - } - - public List getAllTools() { - return new ArrayList<>(tools); - } - - public void clear() { - int size = tools.size(); - if (size > 0) { - tools.clear(); - fireTableRowsDeleted(0, size - 1); - } - } - } - - // ===== CLI Tool Dialog ===== - - private static class CliToolDialog extends com.intellij.openapi.ui.DialogWrapper { - private final JComboBox typeCombo = new JComboBox<>(CliToolConfig.CliType.values()); - private final JBTextField pathField = new JBTextField(); - private final JBTextField argsField = new JBTextField(); - private final JBTextField envVarsField = new JBTextField(); - private final JBTextField mcpConfigFlagField = new JBTextField(); - private final JBCheckBox enabledCheckbox = new JBCheckBox("Enabled", true); - private final JBLabel testResultLabel = new JBLabel(); - private boolean suppressTypeListener = false; - - CliToolDialog(CliToolConfig existing) { - super(true); - setTitle(existing == null ? "Add CLI Tool" : "Edit CLI Tool"); - if (existing != null) { - suppressTypeListener = true; - typeCombo.setSelectedItem(existing.getType() != null ? existing.getType() : CliToolConfig.CliType.CUSTOM); - suppressTypeListener = false; - pathField.setText(existing.getExecutablePath()); - argsField.setText(existing.getExtraArgs() != null ? String.join(" ", existing.getExtraArgs()) : ""); - envVarsField.setText(formatEnvVars(existing.getEnvVars())); - mcpConfigFlagField.setText(existing.getMcpConfigFlag() != null ? existing.getMcpConfigFlag() : ""); - enabledCheckbox.setSelected(existing.isEnabled()); - } - init(); - // Pre-fill defaults for the initially selected type when adding a new entry - if (existing == null) { - onTypeChanged(); - } - } - - @Override - protected JComponent createCenterPanel() { - JPanel panel = new JPanel(new GridBagLayout()); - GridBagConstraints gbc = new GridBagConstraints(); - gbc.fill = GridBagConstraints.HORIZONTAL; - gbc.insets = new Insets(4, 4, 4, 4); - gbc.anchor = GridBagConstraints.WEST; - - // Type selector (first row) - gbc.gridy = 0; - gbc.gridx = 0; - gbc.weightx = 0; - panel.add(new JBLabel("Type:"), gbc); - gbc.gridx = 1; - gbc.weightx = 1.0; - typeCombo.setRenderer(new DefaultListCellRenderer() { - @Override - public java.awt.Component getListCellRendererComponent(JList list, Object value, int index, boolean sel, boolean focus) { - super.getListCellRendererComponent(list, value, index, sel, focus); - if (value instanceof CliToolConfig.CliType t) setText(t.getDisplayName()); - return this; - } - }); - typeCombo.addActionListener(e -> onTypeChanged()); - panel.add(typeCombo, gbc); - - gbc.gridy++; - gbc.gridx = 0; - gbc.weightx = 0; - panel.add(new JBLabel("Executable path:"), gbc); - gbc.gridx = 1; - gbc.weightx = 1.0; - panel.add(pathField, gbc); - - gbc.gridy++; - gbc.gridx = 0; - gbc.weightx = 0; - panel.add(new JBLabel("Extra args:"), gbc); - gbc.gridx = 1; - gbc.weightx = 1.0; - panel.add(argsField, gbc); - - gbc.gridy++; - gbc.gridx = 0; - gbc.weightx = 0; - panel.add(new JBLabel("Env vars:"), gbc); - gbc.gridx = 1; - gbc.weightx = 1.0; - envVarsField.getEmptyText().setText("KEY=VALUE, KEY2=VALUE2"); - panel.add(envVarsField, gbc); - - gbc.gridy++; - gbc.gridx = 0; - gbc.weightx = 0; - panel.add(new JBLabel("MCP config flag:"), gbc); - gbc.gridx = 1; - gbc.weightx = 1.0; - mcpConfigFlagField.setEditable(false); - mcpConfigFlagField.setEnabled(false); - panel.add(mcpConfigFlagField, gbc); - - // Help text for MCP config - gbc.gridy++; - gbc.gridx = 1; - gbc.weightx = 1.0; - JBLabel mcpHelpLabel = new JBLabel("Backlog MCP config is auto-generated and passed to the CLI tool"); - mcpHelpLabel.setForeground(UIManager.getColor("Label.disabledForeground")); - mcpHelpLabel.setFont(mcpHelpLabel.getFont().deriveFont((float) mcpHelpLabel.getFont().getSize() - 1)); - panel.add(mcpHelpLabel, gbc); - - gbc.gridy++; - gbc.gridx = 0; - gbc.gridwidth = 2; - panel.add(enabledCheckbox, gbc); - - // Test button row - gbc.gridy++; - gbc.gridx = 0; - gbc.gridwidth = 2; - JPanel testRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 5, 0)); - JButton testButton = new JButton("Test Connection"); - testButton.addActionListener(e -> runTest()); - testRow.add(testButton); - testResultLabel.setForeground(UIManager.getColor("Label.disabledForeground")); - testRow.add(testResultLabel); - panel.add(testRow, gbc); - - return panel; - } - - private void onTypeChanged() { - if (suppressTypeListener) return; - CliToolConfig.CliType type = (CliToolConfig.CliType) typeCombo.getSelectedItem(); - if (type == null || type == CliToolConfig.CliType.CUSTOM) return; - - // Delegate to the Command for this type — no switch needed - com.devoxx.genie.service.cli.command.CliCommand command = type.createCommand(); - pathField.setText(command.defaultExecutablePath()); - argsField.setText(command.defaultExtraArgs()); - mcpConfigFlagField.setText(command.defaultMcpConfigFlag()); - } - - private void runTest() { - String path = pathField.getText().trim(); - if (path.isEmpty()) { - testResultLabel.setForeground(UIManager.getColor("Component.errorFocusColor")); - testResultLabel.setText("Executable path is empty"); - return; - } - - testResultLabel.setForeground(UIManager.getColor("Label.disabledForeground")); - testResultLabel.setText("Testing..."); - - com.intellij.openapi.application.ApplicationManager.getApplication().executeOnPooledThread(() -> { - try { - // Use the CliCommand abstraction to build command and deliver prompt - // This ensures tool-specific behavior (e.g., --prompt flag for Kimi) - CliToolConfig testConfig = getResult(); - String testPrompt = "Respond with only: OK"; - CliToolConfig.CliType cliType = testConfig.getType() != null - ? testConfig.getType() : CliToolConfig.CliType.CUSTOM; - com.devoxx.genie.service.cli.command.CliCommand cliCommand = cliType.createCommand(); - java.util.List command = cliCommand.buildProcessCommand(testConfig, testPrompt, null); - - ProcessBuilder pb = new ProcessBuilder(command); - pb.redirectErrorStream(false); - - // Inherit the user's shell environment (PATH, tokens, etc.) - pb.environment().putAll(com.intellij.util.EnvironmentUtil.getEnvironmentMap()); - - // Overlay with tool-specific env var overrides - if (testConfig.getEnvVars() != null && !testConfig.getEnvVars().isEmpty()) { - pb.environment().putAll(testConfig.getEnvVars()); - } - - Process process = pb.start(); - - // Delegate prompt delivery to the command - cliCommand.writePrompt(process, testPrompt); - - // Read stdout and stderr in parallel - StringBuilder stdout = new StringBuilder(); - StringBuilder stderr = new StringBuilder(); - - Thread stdoutReader = new Thread(() -> { - try (java.io.BufferedReader reader = new java.io.BufferedReader( - new java.io.InputStreamReader(process.getInputStream()))) { - String line; - int count = 0; - while ((line = reader.readLine()) != null && count < 10) { - if (stdout.length() > 0) stdout.append(" "); - stdout.append(line); - count++; - } - } catch (java.io.IOException ignored) {} - }); - Thread stderrReader = new Thread(() -> { - try (java.io.BufferedReader reader = new java.io.BufferedReader( - new java.io.InputStreamReader(process.getErrorStream()))) { - String line; - int count = 0; - while ((line = reader.readLine()) != null && count < 10) { - if (stderr.length() > 0) stderr.append(" "); - stderr.append(line); - count++; - } - } catch (java.io.IOException ignored) {} - }); - - stdoutReader.setDaemon(true); - stderrReader.setDaemon(true); - stdoutReader.start(); - stderrReader.start(); - - boolean exited = process.waitFor(30, java.util.concurrent.TimeUnit.SECONDS); - if (!exited) { - process.destroyForcibly(); - showTestResult(false, "Timed out after 30s"); - return; - } - - stdoutReader.join(3000); - stderrReader.join(3000); - - int exitCode = process.exitValue(); - if (exitCode == 0) { - showTestResult(true, "Connected successfully"); - } else { - // Prefer stderr for error messages, fall back to stdout - String err = stderr.toString().trim(); - if (err.isEmpty()) err = stdout.toString().trim(); - if (err.isEmpty()) err = "Exit code " + exitCode; - showTestResult(false, err); - } - - } catch (java.io.IOException ex) { - showTestResult(false, "Not found: " + ex.getMessage()); - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - showTestResult(false, "Interrupted"); - } - }); - } - - private void showTestResult(boolean success, String message) { - SwingUtilities.invokeLater(() -> { - String truncated = message.length() > 80 ? message.substring(0, 80) + "..." : message; - if (success) { - testResultLabel.setForeground(new java.awt.Color(0, 128, 0)); - testResultLabel.setText(truncated); - } else { - testResultLabel.setForeground(UIManager.getColor("Component.errorFocusColor")); - testResultLabel.setText(truncated); - } - }); - } - - private static String formatEnvVars(java.util.Map envVars) { - if (envVars == null || envVars.isEmpty()) return ""; - StringBuilder sb = new StringBuilder(); - for (var entry : envVars.entrySet()) { - if (sb.length() > 0) sb.append(", "); - sb.append(entry.getKey()).append("=").append(entry.getValue()); - } - return sb.toString(); - } - - /** - * Strip surrounding shell-style quotes from an argument. - * ProcessBuilder doesn't use a shell, so literal quotes must be removed. - */ - private static String stripShellQuotes(String arg) { - if (arg.length() >= 2) { - if ((arg.startsWith("'") && arg.endsWith("'")) || - (arg.startsWith("\"") && arg.endsWith("\""))) { - return arg.substring(1, arg.length() - 1); - } - } - return arg; - } - - private static java.util.Map parseEnvVars(String text) { - java.util.Map map = new java.util.LinkedHashMap<>(); - if (text == null || text.isEmpty()) return map; - for (String pair : text.split(",")) { - String trimmed = pair.trim(); - int eq = trimmed.indexOf('='); - if (eq > 0 && eq < trimmed.length() - 1) { - map.put(trimmed.substring(0, eq).trim(), trimmed.substring(eq + 1).trim()); - } - } - return map; - } - - public CliToolConfig getResult() { - List args = new ArrayList<>(); - String argsText = argsField.getText().trim(); - if (!argsText.isEmpty()) { - for (String arg : argsText.split("\\s+")) { - args.add(stripShellQuotes(arg)); - } - } - CliToolConfig.CliType selectedType = (CliToolConfig.CliType) typeCombo.getSelectedItem(); - if (selectedType == null) selectedType = CliToolConfig.CliType.CUSTOM; - return CliToolConfig.builder() - .type(selectedType) - .name(selectedType.getDisplayName()) - .executablePath(pathField.getText().trim()) - .extraArgs(args) - .envVars(parseEnvVars(envVarsField.getText().trim())) - .mcpConfigFlag(mcpConfigFlagField.getText().trim()) - .enabled(enabledCheckbox.isSelected()) - .build(); - } - } - - // ===== ACP Tool Table Model ===== - - private static class AcpToolTableModel extends AbstractTableModel { - private static final String[] COLUMNS = {"Enabled", "Type", "Executable Path"}; - private final List tools = new ArrayList<>(); - - @Override - public int getRowCount() { - return tools.size(); - } - - @Override - public int getColumnCount() { - return COLUMNS.length; - } - - @Override - public String getColumnName(int column) { - return COLUMNS[column]; - } - - @Override - public Class getColumnClass(int columnIndex) { - return columnIndex == 0 ? Boolean.class : String.class; - } - - @Override - public boolean isCellEditable(int rowIndex, int columnIndex) { - return columnIndex == 0; - } - - @Override - public Object getValueAt(int rowIndex, int columnIndex) { - AcpToolConfig tool = tools.get(rowIndex); - return switch (columnIndex) { - case 0 -> tool.isEnabled(); - case 1 -> tool.getType() != null ? tool.getType().getDisplayName() : ""; - case 2 -> tool.getExecutablePath(); - default -> ""; - }; - } - - @Override - public void setValueAt(Object value, int rowIndex, int columnIndex) { - if (columnIndex == 0 && value instanceof Boolean) { - tools.get(rowIndex).setEnabled((Boolean) value); - fireTableCellUpdated(rowIndex, columnIndex); - } - } - - public void addTool(AcpToolConfig tool) { - tools.add(tool); - fireTableRowsInserted(tools.size() - 1, tools.size() - 1); - } - - public void updateTool(int row, AcpToolConfig tool) { - tools.set(row, tool); - fireTableRowsUpdated(row, row); - } - - public void removeTool(int row) { - tools.remove(row); - fireTableRowsDeleted(row, row); - } - - public AcpToolConfig getToolAt(int row) { - return tools.get(row); - } - - public List getAllTools() { - return new ArrayList<>(tools); - } - - public void clear() { - int size = tools.size(); - if (size > 0) { - tools.clear(); - fireTableRowsDeleted(0, size - 1); - } - } - } - - // ===== ACP Tool Dialog ===== - - private static class AcpToolDialog extends com.intellij.openapi.ui.DialogWrapper { - private final JComboBox typeCombo = new JComboBox<>(AcpToolConfig.AcpType.values()); - private final JBTextField pathField = new JBTextField(); - private final JBCheckBox enabledCheckbox = new JBCheckBox("Enabled", true); - private final JBLabel testResultLabel = new JBLabel(); - private boolean suppressTypeListener = false; - - AcpToolDialog(AcpToolConfig existing) { - super(true); - setTitle(existing == null ? "Add ACP Tool" : "Edit ACP Tool"); - if (existing != null) { - suppressTypeListener = true; - typeCombo.setSelectedItem(existing.getType() != null ? existing.getType() : AcpToolConfig.AcpType.CUSTOM); - suppressTypeListener = false; - pathField.setText(existing.getExecutablePath()); - enabledCheckbox.setSelected(existing.isEnabled()); - } - init(); - if (existing == null) { - onTypeChanged(); - } - } - - @Override - protected JComponent createCenterPanel() { - JPanel panel = new JPanel(new GridBagLayout()); - GridBagConstraints gbc = new GridBagConstraints(); - gbc.fill = GridBagConstraints.HORIZONTAL; - gbc.insets = new Insets(4, 4, 4, 4); - gbc.anchor = GridBagConstraints.WEST; - - gbc.gridy = 0; - gbc.gridx = 0; - gbc.weightx = 0; - panel.add(new JBLabel("Type:"), gbc); - gbc.gridx = 1; - gbc.weightx = 1.0; - typeCombo.setRenderer(new DefaultListCellRenderer() { - @Override - public java.awt.Component getListCellRendererComponent(JList list, Object value, int index, boolean sel, boolean focus) { - super.getListCellRendererComponent(list, value, index, sel, focus); - if (value instanceof AcpToolConfig.AcpType t) setText(t.getDisplayName()); - return this; - } - }); - typeCombo.addActionListener(e -> onTypeChanged()); - panel.add(typeCombo, gbc); - - gbc.gridy++; - gbc.gridx = 0; - gbc.weightx = 0; - panel.add(new JBLabel("Executable path:"), gbc); - gbc.gridx = 1; - gbc.weightx = 1.0; - panel.add(pathField, gbc); - - gbc.gridy++; - gbc.gridx = 0; - gbc.gridwidth = 2; - panel.add(enabledCheckbox, gbc); - - // Test button row - gbc.gridy++; - gbc.gridx = 0; - gbc.gridwidth = 2; - JPanel testRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 5, 0)); - JButton testButton = new JButton("Test Connection"); - testButton.addActionListener(e -> runTest()); - testRow.add(testButton); - testResultLabel.setForeground(UIManager.getColor("Label.disabledForeground")); - testRow.add(testResultLabel); - panel.add(testRow, gbc); - - return panel; - } - - private void onTypeChanged() { - if (suppressTypeListener) return; - AcpToolConfig.AcpType type = (AcpToolConfig.AcpType) typeCombo.getSelectedItem(); - if (type == null || type == AcpToolConfig.AcpType.CUSTOM) return; - pathField.setText(type.getDefaultExecutablePath()); - } - - private void runTest() { - String path = pathField.getText().trim(); - if (path.isEmpty()) { - testResultLabel.setForeground(UIManager.getColor("Component.errorFocusColor")); - testResultLabel.setText("Executable path is empty"); - return; - } - - testResultLabel.setForeground(UIManager.getColor("Label.disabledForeground")); - testResultLabel.setText("Testing ACP handshake..."); - - com.intellij.openapi.application.ApplicationManager.getApplication().executeOnPooledThread(() -> { - try { - com.devoxx.genie.service.acp.protocol.AcpClient client = - new com.devoxx.genie.service.acp.protocol.AcpClient(text -> {}); - client.start(null, path, "acp"); - client.initialize(); - client.close(); - showTestResult(true, "ACP handshake successful"); - } catch (Exception ex) { - showTestResult(false, ex.getMessage()); - } - }); - } - - private void showTestResult(boolean success, String message) { - SwingUtilities.invokeLater(() -> { - String truncated = message.length() > 80 ? message.substring(0, 80) + "..." : message; - if (success) { - testResultLabel.setForeground(new java.awt.Color(0, 128, 0)); - testResultLabel.setText(truncated); - } else { - testResultLabel.setForeground(UIManager.getColor("Component.errorFocusColor")); - testResultLabel.setText(truncated); - } - }); - } - - public AcpToolConfig getResult() { - AcpToolConfig.AcpType selectedType = (AcpToolConfig.AcpType) typeCombo.getSelectedItem(); - if (selectedType == null) selectedType = AcpToolConfig.AcpType.CUSTOM; - return AcpToolConfig.builder() - .type(selectedType) - .name(selectedType.getDisplayName()) - .executablePath(pathField.getText().trim()) - .enabled(enabledCheckbox.isSelected()) - .build(); - } - } } diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index f273f00f..046ba60b 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -319,6 +319,11 @@ instance="com.devoxx.genie.ui.settings.spec.SpecSettingsConfigurable" displayName="Spec Driven Development (BETA)"/> + + Date: Fri, 13 Feb 2026 09:57:15 +0100 Subject: [PATCH 07/10] feat: add conversation history to CLI/ACP runners and optimize system prompt CLI and ACP runners previously sent each message as standalone with no memory of prior exchanges. Add buildPromptWithHistory() to AbstractPromptExecutionStrategy that prepares memory, retrieves prior messages, and formats them as a text preamble. Also move CLAUDE.md/AGENTS.md and DEVOXXGENIE.md content injection from per-user-message (MessageCreationService) into the system prompt (ChatMemoryManager.buildSystemPrompt), so project context is set once per conversation instead of repeated in every message. Co-Authored-By: Claude Opus 4.6 --- .../genie/service/MessageCreationService.java | 89 +------------------ .../prompt/memory/ChatMemoryManager.java | 86 ++++++++++++++++++ .../AbstractPromptExecutionStrategy.java | 45 ++++++++++ .../prompt/strategy/CliPromptStrategy.java | 2 +- 4 files changed, 135 insertions(+), 87 deletions(-) diff --git a/src/main/java/com/devoxx/genie/service/MessageCreationService.java b/src/main/java/com/devoxx/genie/service/MessageCreationService.java index ab72990e..54b0fae3 100644 --- a/src/main/java/com/devoxx/genie/service/MessageCreationService.java +++ b/src/main/java/com/devoxx/genie/service/MessageCreationService.java @@ -21,12 +21,10 @@ import dev.langchain4j.data.message.UserMessage; import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; -import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Base64; @@ -142,29 +140,10 @@ private void constructUserMessageWithCombinedContext(@NotNull ChatMessageContext String systemPrompt = DevoxxGenieStateService.getInstance().getSystemPrompt(); stringBuilder.append("").append(systemPrompt).append("\n\n"); } - - // Check if DEVOXXGENIE.md should be included in the prompt - if (Boolean.TRUE.equals(DevoxxGenieStateService.getInstance().getUseDevoxxGenieMdInPrompt())) { - // Try to read DEVOXXGENIE.md from project root - String devoxxGenieMdContent = readDevoxxGenieMdFile(chatMessageContext.getProject()); - if (devoxxGenieMdContent != null && !devoxxGenieMdContent.isEmpty()) { - stringBuilder.append("\n"); - stringBuilder.append(devoxxGenieMdContent); - stringBuilder.append("\n\n\n"); - } - } - - // Check if CLAUDE.md or AGENTS.md should be included in the prompt - if (Boolean.TRUE.equals(DevoxxGenieStateService.getInstance().getUseClaudeOrAgentsMdInPrompt())) { - // Try to read CLAUDE.md or AGENTS.md from project root (CLAUDE.md has priority) - String claudeOrAgentsMdContent = readClaudeOrAgentsMdFile(chatMessageContext.getProject()); - if (claudeOrAgentsMdContent != null && !claudeOrAgentsMdContent.isEmpty()) { - stringBuilder.append("\n"); - stringBuilder.append(claudeOrAgentsMdContent); - stringBuilder.append("\n\n\n"); - } - } + // NOTE: DEVOXXGENIE.md and CLAUDE.md/AGENTS.md content is now included in the system prompt + // (set once per conversation in ChatMemoryManager.buildSystemPrompt()) rather than repeated + // in every user message. if (Boolean.TRUE.equals(DevoxxGenieStateService.getInstance().getRagActivated())) { // Semantic search is enabled, add search results @@ -306,68 +285,6 @@ public static List extractFileReferences(@NotNull Map\n"; + } + } + + // Add CLAUDE.md or AGENTS.md content to system prompt (once per conversation) + if (Boolean.TRUE.equals(DevoxxGenieStateService.getInstance().getUseClaudeOrAgentsMdInPrompt())) { + String claudeOrAgentsMdContent = readClaudeOrAgentsMdFile(context.getProject()); + if (claudeOrAgentsMdContent != null && !claudeOrAgentsMdContent.isEmpty()) { + systemPrompt += "\n\n" + claudeOrAgentsMdContent + "\n\n"; + } + } + return TemplateVariableEscaper.escape(systemPrompt); } + + /** + * Read the content of DEVOXXGENIE.md file from the project root directory. + * + * @param project the project + * @return the content of DEVOXXGENIE.md file or null if file not found or can't be read + */ + @Nullable + static String readDevoxxGenieMdFile(Project project) { + try { + if (project == null || project.getBasePath() == null) { + log.warn("Project or base path is null"); + return null; + } + + Path devoxxGenieMdPath = Paths.get(project.getBasePath(), "DEVOXXGENIE.md"); + if (!Files.exists(devoxxGenieMdPath)) { + log.debug("DEVOXXGENIE.md file not found in project root: {}", devoxxGenieMdPath); + return null; + } + + return Files.readString(devoxxGenieMdPath, StandardCharsets.UTF_8); + } catch (IOException e) { + log.warn("Failed to read DEVOXXGENIE.md file: {}", e.getMessage()); + return null; + } + } + + /** + * Read the content of CLAUDE.md or AGENTS.md file from the project root directory. + * CLAUDE.md has priority - if both files exist, only CLAUDE.md is read and AGENTS.md is skipped. + * + * @param project the project + * @return the content of CLAUDE.md or AGENTS.md file or null if neither file is found or can't be read + */ + @Nullable + static String readClaudeOrAgentsMdFile(Project project) { + try { + if (project == null || project.getBasePath() == null) { + log.warn("Project or base path is null"); + return null; + } + + // Try CLAUDE.md first (priority) + Path claudeMdPath = Paths.get(project.getBasePath(), "CLAUDE.md"); + if (Files.exists(claudeMdPath)) { + log.debug("Found CLAUDE.md file in project root, using it (AGENTS.md will be skipped if present)"); + return Files.readString(claudeMdPath, StandardCharsets.UTF_8); + } + + // If CLAUDE.md doesn't exist, try AGENTS.md + Path agentsMdPath = Paths.get(project.getBasePath(), "AGENTS.md"); + if (Files.exists(agentsMdPath)) { + log.debug("Found AGENTS.md file in project root"); + return Files.readString(agentsMdPath, StandardCharsets.UTF_8); + } + + log.debug("Neither CLAUDE.md nor AGENTS.md file found in project root"); + return null; + } catch (IOException e) { + log.warn("Failed to read CLAUDE.md or AGENTS.md file: {}", e.getMessage()); + return null; + } + } } \ No newline at end of file diff --git a/src/main/java/com/devoxx/genie/service/prompt/strategy/AbstractPromptExecutionStrategy.java b/src/main/java/com/devoxx/genie/service/prompt/strategy/AbstractPromptExecutionStrategy.java index 5973d788..7b80611f 100644 --- a/src/main/java/com/devoxx/genie/service/prompt/strategy/AbstractPromptExecutionStrategy.java +++ b/src/main/java/com/devoxx/genie/service/prompt/strategy/AbstractPromptExecutionStrategy.java @@ -11,10 +11,14 @@ import com.devoxx.genie.service.prompt.threading.ThreadPoolManager; import com.devoxx.genie.ui.panel.PromptOutputPanel; import com.intellij.openapi.project.Project; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.SystemMessage; import dev.langchain4j.data.message.UserMessage; import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.NotNull; +import java.util.List; import java.util.concurrent.CancellationException; /** @@ -142,6 +146,47 @@ public void prepareMemory(ChatMessageContext context) { } } + /** + * Builds a prompt string that includes conversation history for strategies + * that communicate via plain text (CLI/ACP runners). + * Prepares memory, adds the user message, then formats prior exchanges + * as a text preamble prepended to the current prompt. + * + * @param context The chat message context + * @return The prompt string, optionally prefixed with conversation history + */ + protected String buildPromptWithHistory(@NotNull ChatMessageContext context) { + prepareMemory(context); + chatMemoryManager.addUserMessage(context); + + List messages = chatMemoryManager.getMessages(project); + + // Collect prior exchanges (skip SystemMessage and the last UserMessage which is the current prompt) + StringBuilder history = new StringBuilder(); + int messageCount = messages.size(); + // The last message is the current user message we just added — exclude it from history + int historyEnd = messageCount - 1; + + for (int i = 0; i < historyEnd; i++) { + ChatMessage msg = messages.get(i); + if (msg instanceof SystemMessage) { + continue; + } + if (msg instanceof UserMessage userMsg) { + history.append("[user]: ").append(userMsg.singleText()).append("\n"); + } else if (msg instanceof AiMessage aiMsg) { + history.append("[assistant]: ").append(aiMsg.text()).append("\n"); + } + } + + String currentPrompt = context.getUserPrompt(); + if (history.isEmpty()) { + return currentPrompt; + } + + return "\n" + history + "\n\n" + currentPrompt; + } + /** * Standardized error handling for execution exceptions. * Hides the "Thinking..." loading indicator when an error occurs. diff --git a/src/main/java/com/devoxx/genie/service/prompt/strategy/CliPromptStrategy.java b/src/main/java/com/devoxx/genie/service/prompt/strategy/CliPromptStrategy.java index 01cce76a..a44aac49 100644 --- a/src/main/java/com/devoxx/genie/service/prompt/strategy/CliPromptStrategy.java +++ b/src/main/java/com/devoxx/genie/service/prompt/strategy/CliPromptStrategy.java @@ -70,7 +70,7 @@ protected void executeStrategySpecific(@NotNull ChatMessageContext context, } CliCommand cliCommand = cliType.createCommand(); - String prompt = context.getUserPrompt(); + String prompt = buildPromptWithHistory(context); List command = cliCommand.buildChatCommand(cliTool, prompt); log.info("CLI chat execute: tool={}, type={}, command={}", toolName, cliType, command); From b1776f40b1f0950cbd0e4d55afff36da1c863423 Mon Sep 17 00:00:00 2001 From: Stephan Janssen Date: Fri, 13 Feb 2026 09:57:20 +0100 Subject: [PATCH 08/10] test: update AcpToolConfigTest for Claude/Copilot types and acpFlag Update enum size assertion from 4 to 6, add coverage for CLAUDE and COPILOT display names and executable paths, and add new test for the defaultAcpFlag field on all AcpType values. Co-Authored-By: Claude Opus 4.6 --- .../genie/model/spec/AcpToolConfigTest.java | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/devoxx/genie/model/spec/AcpToolConfigTest.java b/src/test/java/com/devoxx/genie/model/spec/AcpToolConfigTest.java index 2e67706c..70bebe06 100644 --- a/src/test/java/com/devoxx/genie/model/spec/AcpToolConfigTest.java +++ b/src/test/java/com/devoxx/genie/model/spec/AcpToolConfigTest.java @@ -33,6 +33,8 @@ void testBuilder_customValues() { @Test void testAcpType_displayNames() { + assertThat(AcpToolConfig.AcpType.CLAUDE.getDisplayName()).isEqualTo("Claude"); + assertThat(AcpToolConfig.AcpType.COPILOT.getDisplayName()).isEqualTo("Copilot"); assertThat(AcpToolConfig.AcpType.KIMI.getDisplayName()).isEqualTo("Kimi"); assertThat(AcpToolConfig.AcpType.GEMINI.getDisplayName()).isEqualTo("Gemini"); assertThat(AcpToolConfig.AcpType.KILOCODE.getDisplayName()).isEqualTo("Kilocode"); @@ -41,17 +43,31 @@ void testAcpType_displayNames() { @Test void testAcpType_defaultExecutablePaths() { + assertThat(AcpToolConfig.AcpType.CLAUDE.getDefaultExecutablePath()).isEqualTo("claude-code-acp"); + assertThat(AcpToolConfig.AcpType.COPILOT.getDefaultExecutablePath()).isEqualTo("copilot"); assertThat(AcpToolConfig.AcpType.KIMI.getDefaultExecutablePath()).isEqualTo("kimi"); assertThat(AcpToolConfig.AcpType.GEMINI.getDefaultExecutablePath()).isEqualTo("gemini"); assertThat(AcpToolConfig.AcpType.KILOCODE.getDefaultExecutablePath()).isEqualTo("kilocode"); assertThat(AcpToolConfig.AcpType.CUSTOM.getDefaultExecutablePath()).isEmpty(); } + @Test + void testAcpType_defaultAcpFlags() { + assertThat(AcpToolConfig.AcpType.CLAUDE.getDefaultAcpFlag()).isEqualTo("acp"); + assertThat(AcpToolConfig.AcpType.COPILOT.getDefaultAcpFlag()).isEqualTo("--acp"); + assertThat(AcpToolConfig.AcpType.KIMI.getDefaultAcpFlag()).isEqualTo("acp"); + assertThat(AcpToolConfig.AcpType.GEMINI.getDefaultAcpFlag()).isEqualTo("acp"); + assertThat(AcpToolConfig.AcpType.KILOCODE.getDefaultAcpFlag()).isEqualTo("acp"); + assertThat(AcpToolConfig.AcpType.CUSTOM.getDefaultAcpFlag()).isEqualTo("acp"); + } + @Test void testAcpType_allValues() { AcpToolConfig.AcpType[] values = AcpToolConfig.AcpType.values(); - assertThat(values).hasSize(4); + assertThat(values).hasSize(6); assertThat(values).containsExactly( + AcpToolConfig.AcpType.CLAUDE, + AcpToolConfig.AcpType.COPILOT, AcpToolConfig.AcpType.KIMI, AcpToolConfig.AcpType.GEMINI, AcpToolConfig.AcpType.KILOCODE, From 2c77a8ffe233e331dc5692cc6b4360e93401400e Mon Sep 17 00:00:00 2001 From: Stephan Janssen Date: Fri, 13 Feb 2026 09:57:25 +0100 Subject: [PATCH 09/10] docs: add ACP Runners documentation and update CLI runners page Add new acp-runners.md page covering ACP protocol support, setup instructions, and supported tools (Kimi, Gemini, Kilocode, Claude, Copilot). Update cli-runners.md settings path to reflect the new CLI/ACP Runners settings page. Add ACP runners to sidebar navigation. Co-Authored-By: Claude Opus 4.6 --- docusaurus/docs/features/acp-runners.md | 112 ++++++++++++++++++++++++ docusaurus/docs/features/cli-runners.md | 2 +- docusaurus/sidebars.js | 1 + 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 docusaurus/docs/features/acp-runners.md diff --git a/docusaurus/docs/features/acp-runners.md b/docusaurus/docs/features/acp-runners.md new file mode 100644 index 00000000..dfed9aa4 --- /dev/null +++ b/docusaurus/docs/features/acp-runners.md @@ -0,0 +1,112 @@ +--- +sidebar_position: 4 +title: ACP Runners +description: Execute spec tasks via external ACP (Agent Communication Protocol) tools. DevoxxGenie communicates with ACP-compatible CLIs using JSON-RPC 2.0 over stdin/stdout for structured agent interactions. +keywords: [devoxxgenie, acp runners, agent communication protocol, json-rpc, claude, kimi, gemini, kilocode, agent loop, spec-driven development] +image: /img/devoxxgenie-social-card.jpg +--- + +# ACP Runners + +ACP (Agent Communication Protocol) Runners let you execute spec tasks via **external CLI tools** that speak the ACP protocol — a structured, bidirectional communication layer built on **JSON-RPC 2.0 over stdin/stdout**. Unlike [CLI Runners](cli-runners.md) which pipe plain-text prompts and read stdout, ACP provides typed messages, capability negotiation, and structured streaming. + +ACP Runners integrate with both [Spec-driven Development](spec-driven-development.md) (single task execution) and the [Agent Loop](sdd-agent-loop.md) (batch task execution). + +## How It Works + +DevoxxGenie spawns the ACP tool as a child process and communicates over stdin/stdout using JSON-RPC 2.0. The protocol begins with a capability handshake, then DevoxxGenie sends task prompts as structured messages and receives streaming responses. + +``` +┌──────────────────┐ JSON-RPC 2.0 ┌──────────────────────┐ +│ DevoxxGenie │◀═══════════════▶│ ACP Tool │ +│ (ACP Client) │ stdin/stdout │ (Claude/Kimi/...) │ +└──────────────────┘ └──────────────────────┘ + │ │ + 1. Spawns process 2. Handshake (initialize) + 3. Sends prompt via 4. Streams structured + ACP message response chunks back + 5. Receives completion 6. Process stays alive + with metadata for next message +``` + +### Protocol Flow + +1. **Initialize** — Client sends `initialize` with supported capabilities; the tool responds with its capabilities +2. **Send Prompt** — Client sends the task prompt as a structured ACP message +3. **Receive Response** — Tool streams response chunks back, including text, file operations, and terminal commands +4. **Completion** — Tool signals completion; client processes the final result + +## Supported ACP Tools + +| ACP Tool | Executable | Description | +|----------|-----------|-------------| +| **Claude** | `claude-code-acp` | Claude Code via the [claude-code-acp](https://github.com/zed-industries/claude-code-acp) bridge by Zed Industries | +| **Copilot** | `copilot --acp` | GitHub Copilot CLI in ACP mode | +| **Kimi** | `kimi` | Moonshot AI's coding assistant with ACP support | +| **Gemini CLI** | `gemini` | Google's Gemini CLI with ACP protocol mode | +| **Kilocode** | `kilocode` | Kilocode's AI coding agent | +| **Custom** | *(user-defined)* | Any ACP-compatible tool | + +## Setup + +1. Open **Settings** > **Tools** > **DevoxxGenie** > **CLI/ACP Runners** +2. Scroll to the **ACP Runners** section +3. Click **+** to add a new ACP tool +4. Select the **Type** from the dropdown — the executable path is pre-filled with sensible defaults +5. Adjust the **Executable path** if your CLI is installed in a different location +6. Click **Test Connection** to verify the ACP handshake succeeds +7. Click **OK**, then **Apply** + +### Claude via claude-code-acp + +Claude Code doesn't natively speak ACP, but Zed Industries maintains the [claude-code-acp](https://github.com/zed-industries/claude-code-acp) bridge that wraps Claude Code in an ACP-compatible interface. + +To set it up: + +1. Install the bridge: `npm install -g @anthropic-ai/claude-code-acp` (or follow the repository instructions) +2. In DevoxxGenie, add a new ACP tool with **Type: Claude** — the executable path auto-fills to `claude-code-acp` +3. Ensure Claude Code is installed and authenticated (`claude --version` should work) +4. Click **Test Connection** to verify the ACP handshake + +## Configuration Reference + +| Field | Description | +|-------|-------------| +| **Type** | Preset type (Claude, Copilot, Kimi, Gemini, Kilocode, Custom). Selecting a type auto-fills the executable path. | +| **Executable path** | Path to the ACP-compatible CLI binary (e.g., `claude-code-acp`, `copilot`, `kimi`, `gemini`) | +| **Enabled** | Toggle to enable/disable a tool without deleting its configuration | + +## ACP vs CLI Runners + +| Feature | CLI Runners | ACP Runners | +|---------|-------------|-------------| +| **Communication** | Plain text over stdin/stdout | JSON-RPC 2.0 over stdin/stdout | +| **Protocol** | One-shot process per task | Persistent process with structured messages | +| **Streaming** | Raw stdout stream | Typed response chunks | +| **Capability negotiation** | None | Handshake with capability exchange | +| **MCP integration** | Auto-generated MCP config file | Not required (protocol handles context) | +| **Process lifecycle** | Spawned and terminated per task | Stays alive for multiple interactions | + +:::tip +Claude Code is available both as a **CLI Runner** (direct stdin/stdout) and as an **ACP Runner** (via the claude-code-acp bridge). The ACP mode provides structured communication and richer streaming, while the CLI mode is simpler to set up. +::: + +## Selecting the Execution Mode + +The **DevoxxGenie Specs** toolbar contains an execution mode dropdown: + +- **LLM Provider** — uses the built-in LLM agent (default) +- **CLI: Claude** / **CLI: Copilot** / etc. — uses CLI runners +- **ACP: Claude** / **ACP: Copilot** / **ACP: Kimi** / **ACP: Gemini** / etc. — uses ACP runners + +The selection is persisted across IDE restarts. + +## Troubleshooting + +| Issue | Cause | Solution | +|-------|-------|----------| +| ACP handshake fails | Tool not installed or wrong path | Check the executable path in **Settings > CLI/ACP Runners**, use **Test Connection** to verify | +| "Connection refused" error | Tool doesn't support ACP protocol | Verify the tool version supports ACP. Some tools require a specific flag or version for ACP mode | +| No response after prompt | Process started but not responding | Check that the tool is authenticated. Try running the executable manually with `--version` to verify installation | +| Test Connection times out | Tool is slow to initialize | Some tools need to download models on first run. Try running the tool manually first to complete initial setup | +| Claude ACP bridge not found | `claude-code-acp` not installed globally | Install via `npm install -g @anthropic-ai/claude-code-acp` and ensure it's on your PATH | diff --git a/docusaurus/docs/features/cli-runners.md b/docusaurus/docs/features/cli-runners.md index b2d186c2..f07da9a5 100644 --- a/docusaurus/docs/features/cli-runners.md +++ b/docusaurus/docs/features/cli-runners.md @@ -44,7 +44,7 @@ Codex CLI does not support MCP, so it cannot update task status directly. The ta ## Setup -1. Open **Settings** > **Tools** > **DevoxxGenie** > **Spec Driven Dev** +1. Open **Settings** > **Tools** > **DevoxxGenie** > **CLI/ACP Runners** 2. Scroll to the **CLI Runners** section 3. Click **+** to add a new CLI tool 4. Select the **Type** from the dropdown — all fields are pre-filled with sensible defaults diff --git a/docusaurus/sidebars.js b/docusaurus/sidebars.js index b98ac810..f8564030 100644 --- a/docusaurus/sidebars.js +++ b/docusaurus/sidebars.js @@ -51,6 +51,7 @@ const sidebars = { 'llm-providers/local-models', 'llm-providers/cloud-models', 'llm-providers/custom-providers', + 'features/acp-runners', 'features/cli-runners', ], }, From b29d1f463d2398f54552ee8acde1665d576780ee Mon Sep 17 00:00:00 2001 From: Stephan Janssen Date: Fri, 13 Feb 2026 09:57:30 +0100 Subject: [PATCH 10/10] chore: update docusaurus claude settings Co-Authored-By: Claude Opus 4.6 --- docusaurus/.claude/settings.local.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docusaurus/.claude/settings.local.json b/docusaurus/.claude/settings.local.json index 2a498a3d..e76d1468 100644 --- a/docusaurus/.claude/settings.local.json +++ b/docusaurus/.claude/settings.local.json @@ -1,6 +1,11 @@ { + "enableAllProjectMcpServers": true, "enabledMcpjsonServers": [ "playwright" ], - "enableAllProjectMcpServers": true + "permissions": { + "allow": [ + "Skill(git-commit-push)" + ] + } }