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)" + ] + } } 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', ], }, 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..175a951c --- /dev/null +++ b/src/main/java/com/devoxx/genie/model/spec/AcpToolConfig.java @@ -0,0 +1,45 @@ +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 { + 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, String defaultAcpFlag) { + this.displayName = displayName; + this.defaultExecutablePath = defaultExecutablePath; + this.defaultAcpFlag = defaultAcpFlag; + } + } + + @Builder.Default + private AcpType type = AcpType.CUSTOM; + @Builder.Default + private String name = ""; + @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/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/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 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; + } + } +} diff --git a/src/main/java/com/devoxx/genie/service/prompt/memory/ChatMemoryManager.java b/src/main/java/com/devoxx/genie/service/prompt/memory/ChatMemoryManager.java index ee334c81..58110b3c 100644 --- a/src/main/java/com/devoxx/genie/service/prompt/memory/ChatMemoryManager.java +++ b/src/main/java/com/devoxx/genie/service/prompt/memory/ChatMemoryManager.java @@ -19,7 +19,13 @@ import dev.langchain4j.memory.ChatMemory; 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.List; @@ -317,6 +323,86 @@ private String buildSystemPrompt(@NotNull ChatMessageContext context) { MCPService.logDebug("Added MCP instructions to system prompt"); } + // Add DEVOXXGENIE.md content to system prompt (once per conversation) + if (Boolean.TRUE.equals(DevoxxGenieStateService.getInstance().getUseDevoxxGenieMdInPrompt())) { + String devoxxGenieMdContent = readDevoxxGenieMdFile(context.getProject()); + if (devoxxGenieMdContent != null && !devoxxGenieMdContent.isEmpty()) { + systemPrompt += "\n\n" + devoxxGenieMdContent + "\n\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/AcpPromptStrategy.java b/src/main/java/com/devoxx/genie/service/prompt/strategy/AcpPromptStrategy.java new file mode 100644 index 00000000..7a03403a --- /dev/null +++ b/src/main/java/com/devoxx/genie/service/prompt/strategy/AcpPromptStrategy.java @@ -0,0 +1,177 @@ +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 = buildPromptWithHistory(context); + 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() + "..."); + String acpFlag = acpTool.getAcpFlag() != null ? acpTool.getAcpFlag() : "acp"; + client.start(cwd, acpTool.getExecutablePath(), acpFlag); + + 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/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); 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/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/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 8e00dee4..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,6 +1,5 @@ package com.devoxx.genie.ui.settings.spec; -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; @@ -12,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; /** @@ -47,9 +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); - public SpecSettingsComponent(@NotNull Project project) { this.project = project; JPanel contentPanel = new JPanel(new GridBagLayout()); @@ -116,41 +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(); - // Filler gbc.weighty = 1.0; gbc.gridy++; @@ -159,58 +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); - } - } - - // ===== Existing Helper Methods ===== + // ===== Helper Methods ===== private void addFullWidthRow(JPanel panel, GridBagConstraints gbc, JComponent component) { gbc.gridwidth = 2; @@ -237,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); @@ -333,22 +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(); - } - - private boolean isCliToolsModified() { - List saved = stateService.getCliTools(); - List current = cliToolTableModel.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())); } public void reset() { @@ -356,409 +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); - } - } } @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(); - } - } } 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 { 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)"/> + + 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..70bebe06 --- /dev/null +++ b/src/test/java/com/devoxx/genie/model/spec/AcpToolConfigTest.java @@ -0,0 +1,137 @@ +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.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"); + assertThat(AcpToolConfig.AcpType.CUSTOM.getDisplayName()).isEqualTo("Custom"); + } + + @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(6); + assertThat(values).containsExactly( + AcpToolConfig.AcpType.CLAUDE, + AcpToolConfig.AcpType.COPILOT, + 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