package vip.mate.acp.client;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.extern.slf4j.Slf4j;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
import java.util.function.Function;
/**
* RFC-090 Phase 7 — minimal Java ACP (Agent Communication Protocol)
* client over stdio.
*
*
Implements just enough of the JSON-RPC 2.0 framing to:
*
* - Spawn the agent process ({@code command} + {@code args}).
* - Send {@code initialize} and capture {@code agentCapabilities}
* / {@code protocolVersion}.
* - Optionally open a {@code session/new} handshake.
* - Tear the process down cleanly.
*
*
* This is intentionally a one-shot connection tester (RFC §10.2 Q3
* recommended starting order: codex → claude → opencode → qwen). Full
* bidirectional session prompting / streaming / permission requests is
* a future increment — that needs a proper async bus and ties into the
* agent graph layer.
*
*
Why not the official {@code acp} Python SDK: MateClaw runs on the
* JVM. The protocol is JSON-RPC 2.0 line-delimited over stdio; the
* surface we need for "test connection" is small enough to implement
* directly.
*
*
Each {@link AcpStdioClient} instance owns one Process. Use
* try-with-resources or call {@link #close()} explicitly.
*/
@Slf4j
public class AcpStdioClient implements AutoCloseable {
/** ACP protocol version we advertise (matches v1 ACP-compatible agents). */
public static final int PROTOCOL_VERSION = 1;
private final ObjectMapper mapper;
private final Process process;
private final Writer stdin;
private final BufferedReader stdout;
private final Thread readerThread;
private final AtomicLong nextRequestId = new AtomicLong(1);
private final Map> pending = new ConcurrentHashMap<>();
private volatile boolean closed = false;
/**
* RFC-090 Phase 7b — invoked when the agent sends a JSON-RPC
* notification (no id). Notification objects passed in have shape
* {@code {jsonrpc, method, params}}; the most common is
* {@code session/update} carrying agent message chunks.
*
* Default no-op so existing test-only callers don't need to set
* a handler. {@link AcpDelegationService} installs an accumulator
* that scrapes {@code agent_message_chunk} text into a
* {@code StringBuilder}.
*/
private volatile Consumer notificationHandler = msg -> { /* drop */ };
/**
* RFC-090 Phase 7b — invoked when the agent sends a JSON-RPC
* request (has id). The handler returns the JSON-RPC
* {@code result} object (or null to send back -32601 method-not-
* implemented). Used for {@code session/request_permission};
* trusted endpoints auto-allow, untrusted ones cancel.
*/
private volatile Function requestHandler = msg -> null;
private AcpStdioClient(ObjectMapper mapper, Process process) {
this.mapper = mapper;
this.process = process;
this.stdin = new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8);
this.stdout = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
this.readerThread = new Thread(this::readLoop, "acp-stdio-reader");
this.readerThread.setDaemon(true);
this.readerThread.start();
}
/**
* Spawn the configured agent process. Caller is responsible for
* closing the returned client; failure to do so leaks a child
* process.
*/
public static AcpStdioClient spawn(ObjectMapper mapper,
String command,
List args,
Map envOverrides,
String cwd)
throws IOException {
if (command == null || command.isBlank()) {
throw new IllegalArgumentException("ACP command is required");
}
java.util.List cmdline = new java.util.ArrayList<>();
cmdline.add(command);
if (args != null) cmdline.addAll(args);
ProcessBuilder pb = new ProcessBuilder(cmdline);
Map env = pb.environment();
if (envOverrides != null) env.putAll(envOverrides);
if (cwd != null && !cwd.isBlank()) {
pb.directory(new java.io.File(cwd));
}
// Keep stderr separate from stdout so we don't poison JSON-RPC
// framing when the child agent writes a banner / log line.
pb.redirectErrorStream(false);
Process proc = pb.start();
// Drain stderr in the background — many CLIs print diagnostics
// there (e.g. Zed agents print version on startup).
Thread errDrain = new Thread(() -> drainStream(proc.getErrorStream()), "acp-stdio-stderr");
errDrain.setDaemon(true);
errDrain.start();
return new AcpStdioClient(mapper, proc);
}
private static void drainStream(java.io.InputStream in) {
try (BufferedReader br = new BufferedReader(
new InputStreamReader(in, StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
if (log.isDebugEnabled()) log.debug("[acp-stderr] {}", line);
}
} catch (IOException ignore) {
// Process exited; nothing to do.
}
}
/**
* Send {@code initialize} and wait for the response. Returns the
* response payload's {@code result} object, or throws on protocol
* mismatch / timeout.
*/
public JsonNode initialize(long timeoutMillis) throws IOException, InterruptedException {
ObjectNode params = mapper.createObjectNode();
params.put("protocolVersion", PROTOCOL_VERSION);
// ClientCapabilities — we don't yet implement any client-side
// optional features. Send an empty object so strict agents
// don't reject the request.
params.set("clientCapabilities", mapper.createObjectNode());
ObjectNode info = mapper.createObjectNode();
info.put("name", "mateclaw-acp-client");
info.put("version", "1.0.0");
params.set("clientInfo", info);
return sendRequest("initialize", params, timeoutMillis);
}
/**
* Send {@code session/new} — establishes a session for prompting.
* For the connection-test path we don't actually prompt, just
* verify the server accepts the handshake.
*
* The {@code cwd} parameter is always written into the request
* body. Zed's ACP Zod schema (used by {@code @zed-industries/claude-
* agent-acp} and the codex variant) marks {@code cwd} as a required
* string and returns {@code -32602 Invalid params} when it's
* missing. If the caller passes null/blank we substitute the JVM
* working directory — a workspace-aware default lives in
* {@code AcpRuntimeSupport#resolveCwd}, but this fallback ensures
* the protocol never sees {@code undefined} regardless of caller.
*/
public JsonNode newSession(String cwd, long timeoutMillis)
throws IOException, InterruptedException {
ObjectNode params = mapper.createObjectNode();
String safeCwd = (cwd == null || cwd.isBlank())
? System.getProperty("user.dir", ".")
: cwd;
params.put("cwd", safeCwd);
params.set("mcpServers", mapper.createArrayNode());
return sendRequest("session/new", params, timeoutMillis);
}
/**
* Lower-level request helper. Synchronously awaits the response
* matching the request id. Server-pushed requests (e.g. permission
* prompts) are dropped — the test-only connection path doesn't need
* to handle them.
*/
public JsonNode sendRequest(String method, JsonNode params, long timeoutMillis)
throws IOException, InterruptedException {
if (closed) throw new IOException("ACP client is closed");
long id = nextRequestId.getAndIncrement();
CompletableFuture future = new CompletableFuture<>();
pending.put(id, future);
ObjectNode envelope = mapper.createObjectNode();
envelope.put("jsonrpc", "2.0");
envelope.put("id", id);
envelope.put("method", method);
envelope.set("params", params);
synchronized (stdin) {
stdin.write(mapper.writeValueAsString(envelope));
stdin.write('\n');
stdin.flush();
}
try {
return future.get(timeoutMillis, TimeUnit.MILLISECONDS);
} catch (java.util.concurrent.ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof IOException io) throw io;
throw new IOException("ACP request failed: " + (cause != null ? cause.getMessage() : "unknown"));
} catch (java.util.concurrent.TimeoutException e) {
pending.remove(id);
throw new IOException("ACP request timed out after " + timeoutMillis + "ms");
}
}
private void readLoop() {
try {
String line;
while (!closed && (line = stdout.readLine()) != null) {
if (line.isEmpty()) continue;
try {
JsonNode msg = mapper.readTree(line);
routeMessage(msg);
} catch (Exception e) {
log.warn("ACP malformed line, skipping: {}", e.getMessage());
}
}
} catch (IOException e) {
if (!closed) {
log.debug("ACP stdio reader closed: {}", e.getMessage());
}
} finally {
// If the process exited mid-await, fail every pending future.
for (Map.Entry> entry : pending.entrySet()) {
entry.getValue().completeExceptionally(
new IOException("ACP process exited before responding"));
}
pending.clear();
}
}
private void routeMessage(JsonNode msg) {
JsonNode idNode = msg.get("id");
boolean hasId = idNode != null && !idNode.isNull();
boolean hasMethod = msg.has("method");
// (1) Response to one of *our* outbound requests.
if (hasId && idNode.isNumber() && !hasMethod) {
long id = idNode.asLong();
CompletableFuture future = pending.remove(id);
if (future != null) {
JsonNode error = msg.get("error");
if (error != null && !error.isNull()) {
future.completeExceptionally(
new IOException("ACP error: " + error.toString()));
} else {
future.complete(msg.get("result"));
}
return;
}
}
// (2) Server-initiated request — has both method and id.
if (hasMethod && hasId) {
JsonNode result = null;
try {
result = requestHandler.apply(msg);
} catch (Exception e) {
log.warn("ACP requestHandler threw on method '{}': {}",
msg.path("method").asText(""), e.getMessage());
}
sendReplyTo(idNode, result, msg.path("method").asText(""));
return;
}
// (3) Notification — has method but no id.
if (hasMethod) {
try {
notificationHandler.accept(msg);
} catch (Exception e) {
log.warn("ACP notificationHandler threw on method '{}': {}",
msg.path("method").asText(""), e.getMessage());
}
}
}
private void sendReplyTo(JsonNode idNode, JsonNode result, String method) {
try {
ObjectNode reply = mapper.createObjectNode();
reply.put("jsonrpc", "2.0");
reply.set("id", idNode);
if (result != null) {
reply.set("result", result);
} else {
ObjectNode error = mapper.createObjectNode();
error.put("code", -32601);
error.put("message", "Method not implemented: " + method);
reply.set("error", error);
}
synchronized (stdin) {
stdin.write(mapper.writeValueAsString(reply));
stdin.write('\n');
stdin.flush();
}
} catch (IOException e) {
log.debug("ACP failed to reply to server-initiated request '{}': {}", method, e.getMessage());
}
}
/**
* Replace the notification handler. Pass {@code null} to fall back
* to the no-op default.
*/
public void setNotificationHandler(Consumer handler) {
this.notificationHandler = handler != null ? handler : msg -> {};
}
/**
* Replace the server-request handler. Pass {@code null} to fall
* back to the default which returns -32601 for every method.
*/
public void setRequestHandler(Function handler) {
this.requestHandler = handler != null ? handler : msg -> null;
}
@Override
public void close() {
closed = true;
try {
stdin.close();
} catch (IOException ignore) {
/* best effort */
}
try {
// Give the agent ~1s to exit gracefully after EOF on stdin.
if (!process.waitFor(1, TimeUnit.SECONDS)) {
process.destroy();
if (!process.waitFor(1, TimeUnit.SECONDS)) {
process.destroyForcibly();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
process.destroyForcibly();
}
try {
stdout.close();
} catch (IOException ignore) {
/* best effort */
}
}
/** Convenience for callers that just want a fresh empty env map. */
public static Map emptyEnv() {
return new HashMap<>();
}
}