package vip.mate.acp.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.acp.client.AcpStdioClient;
import vip.mate.acp.model.AcpEndpointEntity;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* RFC-090 Phase 7 — connection tester for ACP endpoints.
*
*
Runs the {@code initialize} + {@code session/new} handshake, with
* a generous-but-bounded timeout, and persists the outcome on the row.
* The wired CLI doesn't have to be installed for the user to add a row;
* they can install it later and re-run the test.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AcpConnectionTester {
/** Hard cap so a hung CLI doesn't block the request thread forever. */
private static final long INITIALIZE_TIMEOUT_MS = 15_000L;
private static final long SESSION_NEW_TIMEOUT_MS = 10_000L;
private final ObjectMapper objectMapper;
private final AcpEndpointService endpointService;
private final AcpRuntimeSupport runtimeSupport;
/**
* Spawn the configured agent, exchange initialize + session/new,
* tear it down, and return a structured result. The endpoint row
* is updated with {@code last_status / last_tested_at / last_error}.
*/
public Map testEndpoint(AcpEndpointEntity endpoint) {
long started = System.currentTimeMillis();
Map result = new LinkedHashMap<>();
result.put("name", endpoint.getName());
result.put("command", endpoint.getCommand());
List args = endpointService.parseArgs(endpoint);
Map env = endpointService.parseEnv(endpoint);
result.put("args", args);
// Same as AcpDelegationService — Zed's ACP server requires a
// non-blank cwd at session/new, so the connection test must
// also default it. The "Test" button used to fail at session/new
// with -32602 even when the CLI itself was healthy.
String resolvedCwd = runtimeSupport.resolveCwd(endpoint, null);
AcpStdioClient client;
try {
client = AcpStdioClient.spawn(objectMapper, endpoint.getCommand(),
args, env, resolvedCwd);
} catch (Exception e) {
return persistAndReturn(endpoint, result, "ERROR",
"Spawn failed: " + e.getMessage(), started);
}
try (AcpStdioClient autoClose = client) {
JsonNode initResp;
try {
initResp = autoClose.initialize(INITIALIZE_TIMEOUT_MS);
} catch (Exception e) {
return persistAndReturn(endpoint, result, "ERROR",
"Initialize failed: " + e.getMessage(), started);
}
if (initResp == null) {
return persistAndReturn(endpoint, result, "ERROR",
"Initialize returned no result", started);
}
int agentProtocolVersion = initResp.path("protocolVersion").asInt(-1);
result.put("protocolVersion", agentProtocolVersion);
if (agentProtocolVersion != AcpStdioClient.PROTOCOL_VERSION) {
String msg = "Protocol mismatch: agent=" + agentProtocolVersion
+ ", client=" + AcpStdioClient.PROTOCOL_VERSION;
return persistAndReturn(endpoint, result, "ERROR", msg, started);
}
// Capture agent capabilities for diagnostics — the UI can
// surface this as "supports: file_system, terminal, …".
JsonNode agentCaps = initResp.path("agentCapabilities");
if (!agentCaps.isMissingNode() && !agentCaps.isNull()) {
result.put("agentCapabilities", agentCaps);
}
// session/new validates that the agent really stands up a
// working session, not just initialize handshake.
try {
JsonNode sessionResp = autoClose.newSession(resolvedCwd, SESSION_NEW_TIMEOUT_MS);
if (sessionResp != null && sessionResp.has("sessionId")) {
result.put("sessionId", sessionResp.path("sessionId").asText(""));
}
} catch (Exception e) {
// session/new may fail for legitimate reasons (e.g. agent
// requires auth flow first). Still report OK on initialize
// but flag in the message — translated when it smells
// like an auth error so the test page UI shows actionable
// text instead of raw JSON-RPC.
String authHint = runtimeSupport.translateAuthError(endpoint, e.getMessage());
result.put("sessionWarning", authHint != null ? authHint : e.getMessage());
}
} catch (Exception e) {
return persistAndReturn(endpoint, result, "ERROR",
"Connection test crashed: " + e.getMessage(), started);
}
long elapsed = System.currentTimeMillis() - started;
result.put("elapsedMs", elapsed);
return persistAndReturn(endpoint, result, "OK", null, started);
}
private Map persistAndReturn(AcpEndpointEntity endpoint,
Map result,
String status,
String error,
long started) {
endpointService.recordTestResult(endpoint.getId(), status, error);
result.put("status", status);
if (error != null) result.put("error", error);
result.putIfAbsent("elapsedMs", System.currentTimeMillis() - started);
return result;
}
}