All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
The six refusals below and the path check come from a whole-specification audit of the SDK against
stable ACP v1: 240 requirements, two judged runs, every FAIL reproduced by a failing test before it
was fixed. The roster, the per-requirement results and the report are published in
protocol-conformance/.
- Upgrade Spring Framework from 7.0.8 to 7.0.10 and Spring Boot from 4.1.0 to 4.1.2, which manages Framework 7.0.10, to address the critical Spring WebFlux advisories GHSA-9qf2-26p9-2q2q and GHSA-j9f9-w8pj-32f8.
- The client SDK now enforces five client-side obligations of stable ACP v1 before a message
leaves, found by the whole-roster conformance audit (
protocol-conformance/, runacp-v1-2797d331-r1-5bb2cf6-001); each was left to the application before and the Javadoc said so.initializefails with the newAcpVersionExceptionand closes the connection when the agent answers with a protocol version this SDK does not speak.authenticaterefuses the ID of aterminalauth method withIllegalArgumentException.session/new,load,resumeandforkrefuse HTTP and SSE MCP servers withAcpCapabilityExceptionunless the agent advertisedmcpCapabilities.http/.sse(newrequireMcpHttp()/requireMcpSse()).session/promptrefuses image, audio and embedded-resource blocks unless the agent advertised the matching prompt capability (newrequireEmbeddedContext()); text and resource links always pass.session/cancelanswers every pendingsession/request_permissionof that session with thecancelledoutcome and stops its handler, so a permission dialog the user never answers no longer leaves the agent waiting. - The client SDK refuses a session call whose
cwdor anadditionalDirectoriesentry is not an absolute path, as stable ACP v1 requires of the client (session setup; found by the conformance audit, runacp-v1-2797d331-r1-44f2a09-002).session/new,load,resumeandforkfail withIllegalArgumentExceptionwithout sending. The form is checked, not the client's own file system, because the agent may run on another host and operating system: a POSIX path (/…), a Windows drive path (C:\…,C:/…) and a UNC or device path (\\server\share…) are all absolute;.,~/…,C:relativeandfile://URIs are not. Migration: pass an absolute path, for examplePath.of(".").toAbsolutePath().toString()instead of".". - The agent SDK sends
booleansession config options only to a client that advertisedsession.configOptions.boolean. To any other client the option is omitted from the session answer, thesession/set_config_optionanswer andconfig_option_update, with a warning naming it; offer aselectin its place to such clients.
0.80.0 - 2026-10-08
- Spring WebFlux on Jetty: Jetty's connection idle timeout, 30 seconds by default, closes an ACP
WebSocket after 30 seconds without traffic. Set
server.jetty.connection-idle-timeout(for example30m), or use Reactor Netty, the default for WebFlux. A fix is planned for 0.80.1. - Micronaut serves ACP on the SDK's listener, on its own port, so Micronaut's own security does not apply to it. This is an interim arrangement until the Netty host planned for 0.81.0.
- An oversized request body may surface as a connection error rather than HTTP 413 on Tomcat (reactive) and Jetty hosts. The request is refused either way; only the error the client sees differs. Bodies within the limit (16 MiB by default) are not affected.
- The outbound queues of the stdio transports and the WebSocket client are unbounded: a peer that keeps sending requests without reading the responses grows the sender's memory, about one response per request. The server-side HTTP and WebSocket hosts are bounded. Bounded outbound queues are planned for 0.81.0.
-
Every WebSocket host now closes idle connections (after
webSocketIdleTimeout, 30 minutes by default, close code 1001) and connections that do not sendinitializewithininitializeTimeout(30 s by default, close code 1008). The idle timeout previously applied only on the servlet and Jetty hosts; WebFlux and Quarkus had none, and no host closed a WebSocket that never initialized. -
Quarkus: HTTP security policies now apply to the WebSocket handshake. The extension's WebSocket route ran ahead of Quarkus's authentication and permission checks (order
Integer.MIN_VALUE), so an unauthenticated client was upgraded on a path thatquarkus.http.auth.permissionprotected (101 instead of 401). The endpoint is now one Vert.x route in the router's ordinary order, behind those checks, for HTTP and WebSocket alike. -
The SDK's listener binds the loopback interface by default.
StreamableHttpAcpAgentTransportset a port and no host on its Jetty connector, so it listened on every network interface, and anyone who could reach the machine could start an agent: the endpoint has no authentication of its own. It now binds127.0.0.1, and::1too where the machine has IPv6, unless told otherwise. Remote exposure is an explicit opt-in:StreamableHttpAcpAgentTransportOptions.builder() .host("0.0.0.0")(or one address),spring.acp.agent.transport.http.listener.host(Spring Boot, non-web applications),acp.agent.transport.http.listener.host(Micronaut) andtransport.http.listener.hostinAcpAgentSettings. Servers a framework runs (a Spring MVC servlet container, Quarkus) keep the framework's own bind settings (server.address,quarkus.http.host). Migration: a deployment that reached the listener from another machine or a container sets the host to0.0.0.0, ideally behind a proxy that authenticates. -
Browser requests from a foreign origin are refused (403). Any web page the user visited could POST to, or open a WebSocket to, an agent on
localhost(cross-site requests, DNS rebinding). Every host now checks theOriginheader, on HTTP requests and on the WebSocket handshake: a request without one (any non-browser client) is served, and so is one fromhttp(s)://localhost,127.0.0.1or[::1]on any port; any other origin gets 403 unless it is listed. The rule lives in the shared endpoint code, so the servlet, the SDK listener (HTTP and WebSocket) and Quarkus (servlet and WebSocket route) all apply it. New:StreamableHttpAcpAgentTransportOptions.Builder.allowedOrigins(Collection)(*for any) andisOriginAllowed(String);spring.acp.agent.transport.http.allowed-origins,acp.agent.transport.http.allowed-origins(Micronaut),quarkus.acp.agent.transport.http.allowed-origins, andtransport.http.allowed-originsinAcpAgentSettings. Migration: a browser application served from another origin that talks to the agent directly lists that origin. -
An invalid JSON-RPC request no longer reaches the logs.
AcpSchema.deserializeJsonRpcMessagerefused a request or notification JSON-RPC 2.0 does not allow (nojsonrpcor one other than"2.0", a method that is not a string, an id that is not a string, number or null) with an exception whose message quoted the whole message text, and every transport logs that exception at WARN or ERROR: the stdio agent and client, the WebSocket and Streamable HTTP clients, and the server's WebSocket on every host (the SDK listener, the servlet, WebFlux, Quarkus). A peer's malformedsession/promptput the user's prompt in the log. The message now names the member at fault only. -
The WebSocket client logs its endpoint without the query or user information.
WebSocketAcpClientTransportlogged the full endpoint URI at INFO when connecting and at ERROR when the connect failed; a token in the query string (?token=...) or a password in the URI reached the log. It now logs scheme, host, port and path. -
A peer's error message and data are no longer logged above DEBUG. An error answer is the peer's own text. Three log lines at WARN carried it: a peer's error report for a message it could not read (
PendingResponses), a notification handler that failed with the peer'sAcpError, and a failedterminal/releaseof a cancelledexecute. Each now logs the error code and the SDK's name for it (-32603 Internal error); the peer's message and data are logged at DEBUG. -
Documented: what the SDK's logs can carry. A stdio agent's standard error is logged at INFO, unfiltered, on
com.agentclientprotocol.sdk.client.transport.agent-stderr; silence it with that logger's level orsetStdErrorHandler. DEBUG and TRACE oncom.agentclientprotocol.sdklog message payloads, session updates included, and should not be enabled where logs are shipped. -
A handler's unexpected exception no longer sends its message to the peer. A request handler that failed with an exception other than
AcpProtocolExceptionwas answered-32603with the exception's message, which can carry paths, SQL, URLs with credentials or tokens. It is now answered-32603with the message "Internal error", on both sides, and the exception is logged at WARN, with its stack trace, by the side that handled the request. AnAcpProtocolExceptionis unchanged: its code, message and data are the handler's intended answer. Migration: a handler whose exception message the peer should see throwsAcpProtocolExceptionwith that message. -
Jackson 2.22.2 → 2.22.3 and Jackson 3.1.5 → 3.1.7, clearing CVE-2026-89407, CVE-2026-89425, CVE-2026-91776, CVE-2026-91777 (both lines) and CVE-2026-19032, CVE-2026-68497, CVE-2026-83557 (Jackson 3). 0.18.0 shipped with the affected versions; applications can override the versions now.
-
StreamableHttpAcpAgentTransportOptions.Builder.webSocketIdleTimeout(Duration)(default 30 minutes) andinitializeTimeout(Duration)(default 30 seconds), with the record components of the same names. The endpoint closes a WebSocket connection idle forwebSocketIdleTimeoutwith 1001 and one that has not sentinitializewithininitializeTimeoutwith 1008, on every host;initializeTimeoutalso bounds the agent's answer to a POSTinitialize(500 after it), which was a fixed 30 seconds.AcpWsHandshake.Accepted.idleTimeout()returns the configured value plus five seconds, so the servlet's Jakarta WebSocket session idle timeout follows it as a backstop that never races the endpoint's own close. Framework properties:spring.acp.agent.transport.http.web-socket-idle-timeoutand…initialize-timeout(Spring Boot),acp.agent.transport.http.web-socket-idle-timeoutand…initialize-timeout(Micronaut),quarkus.acp.agent.transport.http.web-socket-idle-timeoutand…initialize-timeout(Quarkus), andtransport.http.web-socket-idle-timeoutandtransport.http.initialize-timeoutinAcpAgentSettings(withLimits.webSocketIdleTimeout,Limits.initializeTimeoutand the builder methods of the same names). -
One framework-neutral ACP endpoint and a host contract:
acp-http-core. Every protocol rule of the Streamable HTTP and WebSocket transports now lives in one place,com.agentclientprotocol.sdk.http.server.AcpHttpEndpoint: routing, sessions, the bounded SSE mailboxes and WebSocket send queue, the status and close-code table, theOrigincheck, keep-alive and drain. A server adapter (a host) only adapts I/O through the contract typesAcpHttpExchange,AcpHttpReply(Body,Empty,EventStream),SseFrame,AcpWsHandshake,AcpWsOutboundandAcpWsHandler, all@UnstableAcpApi: applications use the hosts the SDK ships. The one neutral WebSocket connection replaces the Jetty-native one. -
WebSocket on the servlet, on the application's own server.
StreamableHttpAcpServlet(new moduleacp-http-servlet, no Jetty dependency) upgradesUpgrade: websocketrequests on its own path through Jakarta WebSocket 2.1 (ServerContainer.upgradeHttpToWebSocket), on Tomcat, Jetty and Undertow. A container without a Jakarta WebSocket implementation answers the upgrade 501. Its outbound frames are serialized, one write at a time, as Tomcat requires. -
Spring Boot: ACP on
server.port, under the application's security. A servlet web application now serves Streamable HTTP, SSE and WebSocket on its own server, through its filter chain: Spring Security rules on/acpapply to HTTP and to the WebSocket handshake, and the principal reaches the endpoint (AcpHttpExchange.principal()). The endpoint is anAcpHttpEndpointbean. Withserver.compression.enabled,text/event-streamis no longer compressed. -
Shutdown drains open streams. Closing the endpoint (servlet, listener, Spring's
SmartLifecyclebefore graceful shutdown) gives each open SSE stream a closing comment (: shutting down) and completes it, and closes each WebSocket with 1001 (going away), so a framework's graceful shutdown never waits for them. -
SSE responses carry
X-Accel-Buffering: no, so nginx and similar proxies do not buffer them, besidesCache-Control: no-cache. -
Spring WebFlux host:
acp-http-webflux.AcpWebFluxHostmounts the endpoint as aRouterFunction(routerFunction("/acp"), orhandle(ServerWebExchange)as aWebHandler): Streamable HTTP, SSE and WebSocket on one path of a WebFlux application's own server, Reactor Netty, Tomcat or Jetty, behind the application'sWebFilters. It resolves the reactive principal (ServerWebExchange.getPrincipal(), Spring Security's authentication) before the endpoint reads it; flushes each SSE frame; aggregates a request body no further than one byte past the limit; on Reactor Netty raises the WebSocket frame limit above the endpoint's, so an oversized message closes with 1009 rather than 1011; and closes a WebSocket with the endpoint's close code before it ends the outbound stream, so clients never see 1005. WebFlux reports no completion per WebSocket frame, so the host hands WebFlux a frame only on its demand: a client that stops reading fills the endpoint's bounded send queue (maxWebSocketPendingFrames) and the socket closes with 1011, instead of frames piling up in the host. The transport TCK runs it on Reactor Netty and on Tomcat. -
Spring Boot: ACP in a reactive (WebFlux) web application, on
server.port. Withspring.acp.agent.transport.type=http(orwebsocket) andacp-http-webfluxon the classpath, a reactive application routesspring.acp.agent.transport.http.pathto the endpoint with aRouterFunctionbean namedacpRouterFunction(an application's own bean of that name replaces it), on its own server, behind itsSecurityWebFilterChain: security rules on/acpapply to HTTP and to the WebSocket handshake, and the principal reaches the endpoint. The samespring.acp.agent.transport.*properties apply; no new keys. The endpoint is anAcpHttpEndpointbean, drained by aSmartLifecyclebefore the server's graceful shutdown, and withserver.compression.enabled,text/event-streamis taken out of the compressed types, as in a servlet application. The application'shttp.server.requestsobservations cover/acp. -
A shared transport TCK in
acp-test:com.agentclientprotocol.sdk.test.http.AcpHttpTransportTck, the suite every host runs (the servlet on Tomcat 11 and Jetty 12.1, the embedded listener, Spring MVC), withHttpProbesfor raw HTTP and WebSocket checks.acp-testnow depends onjunit-jupiter-api,assertj-coreandacp-http-core. -
AcpTransportThreadsinacp-integration: the threads a framework's network transports run on.AcpClientTransports.create(settings, prefix, threads)andAcpListeners.listener(settings, factory, threads)take one:executor(Executor)for the framework's executor,sdkDefault()for the SDK's own threads (virtual on JDK 21 and later), orplatform()for platform threads on every JDK. The existing overloads usesdkDefault(). The stdio transports keep their own threads, which block on the process's pipes. -
The network transports can run on the application's executor.
StreamableHttpAcpClientTransportOptions.builder().executor(Executor)runs the Streamable HTTP client's work (the defaultHttpClient, handing over HTTP results, reading each SSE stream) on that executor;new WebSocketAcpClientTransport(URI, AcpJsonMapper, Executor)runs the WebSocket client'sHttpClientand its frame writer on it; andStreamableHttpAcpAgentTransportOptions.builder().executor(Executor)(JDK 21 and later) serves the listener on Jetty'sVirtualThreadPoolwith every task on it. None of them creates a pool of its own then, and none shuts the executor down: the application owns it. A JDKHttpClientstill keeps its one selector thread, and Jetty'sVirtualThreadPoolparks one platform thread while the listener runs. -
virtualThreads(boolean)onStreamableHttpAcpClientTransportOptions.BuilderandStreamableHttpAcpAgentTransportOptions.Builder(defaulttrue):falsekeeps the transport's platform-thread pools on every JDK, for an application that has not opted into virtual threads. -
Client prompt cancellation in the completion menu:
prompt(request, CancellationSignal).AcpAsyncClient.prompt(request, stop)andAcpSyncClient.prompt(request, stop)take a newcom.agentclientprotocol.sdk.client.CancellationSignal; callingstop.cancel(), from any thread, sendssession/cancelfor the prompt's session, once, and the prompt still returns the agent's answer (stop reasoncancelled) with the turn's updates. Cancelling after the answer sends nothing. Before, stopping a turn and keeping its answer tookcontextWrite(RequestCancellation.cancelWhen(trigger)), which no completion menu offers and the sync client could not use;RequestCancellationstays, for$/cancel_requeston any request. Migration: none;prompt(request)is unchanged. To stop a turn and keep its answer, replaceprompt(request).contextWrite(RequestCancellation.cancelWhen(trigger))withprompt(request, stop)and callstop.cancel(). -
Builder handlers receive their agent. Every typed request setter of
AcpAgent.async(..)andAcpAgent.sync(..)exceptpromptHandlerhas an overload taking anAgentAwareHandler<Request, Response>(async,(request, AcpAsyncAgent agent)) orSyncAgentAwareHandler<Request, Response>(sync,(request, AcpSyncAgent agent)); a two-argument lambda picks it. The agent is the onebuild()returned, so asession/loadreplay or aConfigOptionUpdateaftersession/set_config_optionno longer needs anAtomicReferenceholding the built agent:.setSessionConfigOptionHandler((request, self) -> { self.sendSessionUpdate(request.sessionId(), update); return response; }). One-argument handlers are unchanged. The two interfaces are top-level types incom.agentclientprotocol.sdk.agent. Migration: none; a handler that read the built agent from anAtomicReferencecan take it as its second parameter instead:.loadSessionHandler(request -> agentRef.get()...)→.loadSessionHandler((request, self) -> self...). Name the agent parameterself(or anything but the variable the built agent is assigned to):AcpSyncAgent agent = AcpAgent.sync(t).newSessionHandler((request, agent) -> ...)does not compile, since a lambda parameter cannot shadow a local variable. -
Extension methods from inside handlers.
SessionClient(PromptContext.client()) gainssendExtRequest(String, Object, TypeRef<T>),sendExtRequest(String, Object)andsendExtNotification(String, Object), andSyncSessionClient(SyncPromptContext.client()) their blocking forms, the same calls asAcpAsyncAgent/AcpSyncAgent, so a prompt handler can send_-prefixed requests and notifications to the client:context.client().sendExtRequest("_example.com/ping", params, PONG).extRequestHandlerandextNotificationHandleron both agent builders gain agent-aware overloads, typed and raw: a two-argument lambda receives the params and the agentbuild()returned (AgentAwareHandler<T, R>andAgentAwareHandler<T, Void>on the async builder,SyncAgentAwareHandler<T, R>andBiConsumer<T, AcpSyncAgent>on the sync one), for example.extRequestHandler("_example.com/ask", ASK, (ask, self) -> self.sendExtRequest("_example.com/confirm", ask, ANSWER)). Migration: none; a test double that implementsSessionClientorSyncSessionClientadds the three methods. -
Capability builders that lead to each choice.
ClientCapabilities.builder()gainsreadTextFile(),writeTextFile(),terminal(),elicitationForm()andelicitationUrl();AgentCapabilities.builder()gainsloadSession(),promptImage(),promptAudio(),promptEmbeddedContext(),mcpHttp()andmcpSse(). Each sets one flag and keeps the others (and the nested record's_meta), soClientCapabilities.builder().readTextFile().terminal().build()replacesfs(new FileSystemCapability(true, false)).terminal(true)and its two adjacent booleans. The setters that take the nested records stay. Migration: none; optionally.fs(new FileSystemCapability(true, true))→.readTextFile().writeTextFile()and.loadSession(true)→.loadSession(). -
Micronaut:
acp.client.capabilities.elicitation-form,elicitation-urlandboolean-config-options(defaultfalse), as Spring Boot and Quarkus already offer. The Micronaut client configuration hard-coded the three as not advertised, so a Micronaut client could not advertise elicitation or boolean config options from its settings. -
Transport constructors without a JSON mapper:
StreamableHttpAcpClientTransport(URI),WebSocketAcpClientTransport(URI),StreamableHttpAcpAgentTransport(int port, AcpAgentFactory)andStreamableHttpAcpServlet(AcpAgentFactory)useAcpJsonMapper.createDefault(), as the stdio transports already did. -
AgentParameters.Builder.inheritEnvironment(boolean): start a stdio agent without the client's environment. The "safe" default variables ofAgentParameters(HOME, PATH, USER and the like) had no effect:StdioAcpClientTransportadded them to the whole environment the process inherits, so every secret in the client's environment reached the agent. Inheriting stays the default and is now documented as such. WithinheritEnvironment(false)the process starts from an empty environment and gets onlygetEnv(): the safe defaults and the variables added on the builder (addEnvVar("GEMINI_API_KEY", ...)for a key the agent needs).AgentParameters.isInheritEnvironment()reports the choice. -
Annotated handlers may return a
CompletionStageor a single-valuePublisher, besides the value itself and aMono, with the same meaning: the runtime waits for the value on the handler's thread. A@Promptmethod'sMono<String>(orCompletionStage<String>) now sends the text as an agent message chunk and ends the turn, as aStringdoes; it used to fail every prompt with-32603. Breaking:MonoHandleris renamedAsyncValueHandler, andgetMonoGenericTypeis nowgetValueType. -
AcpAgentSupport.Builder.run()builds the agent on its transport, starts it and blocks until the transport ends:AcpAgentSupport.create(new MyAgent()).transport(new StdioAcpAgentTransport()).run(), the bootstrap the README and Javadoc showed, now compiles. -
Typed values in
@SetSessionConfigOptionmethods. New parameter annotations@ConfigId(the option id, aString) and@ConfigValue(the new value, typed by the parameter:Stringfor a select option,boolean/Booleanfor a boolean option,Objectfor either), resolved by the newConfigOptionResolver. A value of the other kind than the parameter's is answered-32602(invalid params) without calling the method. The SDK still does not reject an id or a value the session never offered; that needs per-session option tracking and is the method's to check. -
Spring Boot autoconfiguration and starter, now part of the SDK. The former
spring-ai-community/acp-autoconfigproject moves into this repository, with its history, as two modules released with the SDK:com.agentclientprotocol:acp-spring-boot-starterandcom.agentclientprotocol:acp-spring-boot-autoconfigure, for Spring Boot 4.1. Boot's BOM is imported by these two modules only; the SDK's other modules do not depend on Spring.- Packages
com.agentclientprotocol.sdk.spring.boot.autoconfigure(.client,.agent). - Client: an
AcpSyncClientand anAcpAsyncClientover one transport fromspring.acp.client.transport.*: stdio (stdio.command), WebSocket (websocket.uri) or Streamable HTTP (http.uri). An explicittypewithout its property fails at startup, naming the property.AcpClientCustomizerbeans register session-update, permission, file system and terminal handlers. File system and terminal capabilities are off by default: advertise one only together with its handler. - Agent: the application's single
@AcpAgentbean is served over stdio (the default) or, withspring.acp.agent.transport.type=httpand one HTTP module on the classpath, over Streamable HTTP and WebSocket:acp-http-servletin a servlet web application andacp-http-webfluxin a reactive one, both on the application's own server, oracp-streamable-http-jetty, the SDK's listener, in an application without a web server. A stdio agent closes the application context when its client closes stdin, so aspring.main.keep-aliveapplication exits 0 (spring.acp.agent.shutdown-on-transport-end).
Migrating from
org.springaicommunity:acp-spring-boot-starter0.12.0 (the old repository is redirected and publishes no further releases):- Coordinates:
org.springaicommunity:acp-spring-boot-starterbecomescom.agentclientprotocol:acp-spring-boot-starter, with the SDK's version. Drop any separate ACP SDK version pin; the starter brings the matching SDK modules. - Imports:
com.agentclientprotocol.autoconfigure.*becomescom.agentclientprotocol.sdk.spring.boot.autoconfigure.*. Most applications import nothing from these packages; they configurespring.acp.*, whose keys are unchanged except as listed here. - Behaviour changes since 0.12.0:
- the
spring.acp.agent.transport.websocket.*properties are removed; they never configured anything; spring.acp.client.capabilities.read-text-fileandwrite-text-filenow default tofalse;- a client-only application no longer gets an agent transport bean;
- session updates with no consumer of your own are logged at DEBUG instead of a WARN;
spring.acp.agent.request-timeoutandspring.acp.client.request-timeouthave no default of their own: unset, they keep the SDK's one default request timeout (60 s), so the property follows the SDK;- several client transports set with no
spring.acp.client.transport.typefail at startup, naming them; the websocket, then http, then stdio one used to win silently. Set the type, or leave only one set; - the listener-only agent properties move:
spring.acp.agent.transport.http.portbecomesspring.acp.agent.transport.http.listener.port, and...http.max-concurrent-streams-per-connectionbecomes...http.listener.max-concurrent-streams-per-connection. In a servlet web application they meant nothing; spring.acp.agent.transport.type=websocketis accepted and means the same ashttp: the endpoint takes WebSocket upgrades on its path;- new properties:
spring.acp.client.prompt-timeout,spring.acp.client.capabilities.elicitation-form,elicitation-urlandboolean-config-options,spring.acp.agent.cancel-grace-periodandspring.acp.agent.max-prompt-duration; ArgumentResolverandReturnValueHandlerbeans are added to the agent, asAcpInterceptorbeans were;AcpClientCustomizermoves tocom.agentclientprotocol.sdk.integration, andTransportTypeis replaced bycom.agentclientprotocol.sdk.integration.AcpTransportType;- closing the client waits at most its request timeout plus 10 s, then closes it at once;
- a second
@AcpAgentbean fails with "Found 2 @AcpAgent beans [...]", namingspring.acp.agent.enabled=falseas the way to serve none; - the agent's handler methods can run on a Spring executor:
spring.acp.agent.handler-executornames anExecutorbean (a plainTaskExecutoris adapted), ornonefor the SDK's own pool. Unset, they run on the context'sapplicationTaskExecutorwhenspring.threads.virtual.enabled=true(a virtual thread per handler), and on the SDK's pool otherwise: without virtual threads Boot's executor is a pool of 8 threads, which would cap the prompts served at once.
- the
- Everything else in this release's breaking changes applies too: Spring applications compile against the SDK's API.
-
acp-integration: the framework-neutral half of the framework integrations, packagecom.agentclientprotocol.sdk.integration. The Spring Boot, Micronaut and Quarkus integrations are thin layers over it, so each rule below holds the same in all three. Depends onacp-coreandacp-agent-support, withacp-streamable-http-jettyoptional, and on no framework (ArchUnit). Seeacp-integration/README.md.- Settings:
AcpAgentSettingsandAcpClientSettings, records with builders, andfrom(SettingsSource, prefix)for kebab-case key/value configuration. An unset value keeps the SDK default.AcpTransportType. - Transports:
AcpClientTransports(an explicit type wins; otherwise the one transport configured; several with no type fail, naming them),AcpAgentTransports.stdio(), andAcpListenersfor the SDK's listener and servlet, withisListenerAvailable(). - Agents:
AcpAgentDiscovery.requireSingleoverAgentCandidates (user class plus instance supplier, so a proxied bean keeps its advice) or over class names for build-time discovery;AcpAgents.builder. - Clients:
AcpClients(one async client and its sync facade, a DEBUG session-update consumer, the customizers in order) andAcpClientCustomizer, now one type for every framework. - Lifecycles: the
AcpHostcontract (start,stopGracefully,stop(Duration),termination,port,holdJvmUntilTermination) withAcpAgentHost(one transport; a latchedonTransportEndaction on a host thread, never run when the host stopped the agent) andAcpListenerHost;AcpServletHostandAcpClientHost. - Handler executors:
AcpAgents.builder(..., handlerExecutor)passes a framework's executor toAcpAgentSupport.Builder.handlerExecutor, so annotated handlers run on the framework's threads instead of a second pool; unset keeps the SDK's own pool.
- Settings:
-
acp-micronaut: Micronaut 4 integration (Java 17+), withacp-micronaut-sample(not published).- Agent. Annotate an
@AcpAgentclass@Singletonand it is served with the application context. The bean is found from its compile-time bean definition; exactly one is allowed.AcpInterceptor,ArgumentResolverandReturnValueHandlerbeans are added to it, and handlers may also return a Reactive StreamsPublisher. Configured underacp.agent.*: stdio by default (the context closes and the process exits 0 when the client closes the agent's input; SIGTERM closes the agent gracefully), orhttp/websocketthrough the SDK's Jetty listener on its own port, with everyStreamableHttpAcpAgentTransportOptionslimit as a property, pluscancel-grace-periodandmax-prompt-duration. As in Spring Boot andacp-integration, the listener's own keys sit underacp.agent.transport.http.listener(host,port,max-concurrent-streams-per-connection); the keys any host of the endpoint reads (path,allowed-origins, the buffer limits,shutdown-timeout) stay underacp.agent.transport.http. - Client.
acp.client.*builds the stdio, WebSocket or Streamable HTTP transport, anAcpAsyncClientcustomized byAcpClientCustomizerbeans in order, and anAcpSyncClientover it; it closes gracefully, once, with the context. - The Micronaut platform BOM (4.10) is imported only by these modules, after the SDK's Reactor, Jackson, Jetty and JUnit BOMs, so they run on the SDK's versions. The wire format stays the SDK's Jackson mapper.
- Built on
acp-integration:AcpClientCustomizerand the transport type are itscom.agentclientprotocol.sdk.integrationtypes, and an explicit client type without its property fails with "...transport.type=http requires ...transport.http.uri". - On JDK 21 and later the agent's handler methods run on Micronaut's virtual-thread executor
(
TaskExecutors.VIRTUAL) instead of the SDK's own pool; on JDK 17 they stay on the SDK's pool.
- Agent. Annotate an
-
Quarkus extension:
acp-quarkus(withacp-quarkus-deployment). One@AcpAgentclass becomes a singleton bean (found at build time; a second one fails the build) and is served over stdio, or over Streamable HTTP and WebSocket on the Quarkus HTTP server (quarkus.acp.agent.transport.type=http): one Vert.x route on the Quarkus router for HTTP, SSE and WebSocket, no servlet container and no second server. Stdio builds keep standard output for the protocol (console log on stderr, no banner, no HTTP listener) and exit when input ends. Handlers may return MutinyUni, and@Promptmay stream aMulti.AcpSyncClient/AcpAsyncClientbeans come fromquarkus.acp.client.*withAcpClientCustomizerbeans. Configuration mirrors the Spring Boot starter's (quarkus.acp.*; the HTTP port isquarkus.http.port). JVM mode only. Built on Quarkus 3.40.1, whose BOM is imported only by these modules. Seeacp-quarkus/README.md. Cross-SDK:quarkus-typescript-httpandquarkus-typescript-wsrun the TypeScript client against a Quarkus-hosted agent. Built onacp-integration:AcpClientCustomizerand the transport type are itscom.agentclientprotocol.sdk.integrationtypes,quarkus.acp.agent.transport.typealso acceptswebsocket(the same ashttp), and several client transports set with noquarkus.acp.client.transport.typefail at startup, naming them, as in the other frameworks. The agent's handler methods run on theManagedExecutor(Quarkus' worker pool, with context propagation) instead of the SDK's own pool. -
Prompt handlers see their prompt's cancellation.
SyncPromptContext.isCancelled()andonCancel(Runnable), andPromptContext.isCancelled()andwhenCancelled()(aMono<Void>that completes on cancel), signal a cancel bysession/cancelfor the prompt's session or by$/cancel_requestfor its request, and also when the agent cancels the handler itself (the cancel grace period ormaxPromptDurationpassed, or the connection closed). An annotated@Promptmethod reads it from the context it takes, with no@Cancelhandler or shared state; a builder prompt handler likewise. Aftersession/cancelthe handler still answerscancelledwithin the cancel grace period; after$/cancel_requestthe agent has already answered, so the handler just stops. NewPromptResponse.cancelled(). Breaking for code implementingPromptContextorSyncPromptContextitself (test doubles): implement the new methods. -
$/cancel_request(ACP v1, Cancellation), both directions. Client and agent sessions alike:- Cancelling a request you sent: dispose (cancel) the subscription to its
Monobefore the response arrives, directly or throughtimeout(...),take..., or the SDK's own request timeout, and the session sends$/cancel_request {"requestId": ...}to the peer, once, after the request itself was written (never for a request that failed to send, was already answered, or was dismissed by closing the session). The caller'sMonois cancelled at once (Reactor semantics); the peer's late answer is expected and discarded at DEBUG. An agent prompt handler whose subscription is cancelled cancels thefs/*,terminal/*andsession/request_permissionrequests it is waiting on the same way. Interrupting a thread blocked in a sync client or agent call cancels its request too. - Graceful cancel:
request.contextWrite(RequestCancellation.cancelWhen(trigger))sends$/cancel_requestwhentriggeremits or completes but keeps waiting, so the request ends with the peer's answer: its result, or anAcpErrorwith code-32800. Works for every typed request method on both sides; at most one$/cancel_requestper request either way. - Answering a cancelled request: on
$/cancel_requestthe session cancels the handler's subscription and answers the request with error-32800(Request cancelled), unless the handler answered first; exactly one response either way (model checked with Lincheck,InboundRequestsLincheckTest). An unknown or already answered id is ignored. A cancelledsession/promptends its turn before the answer is published; one already cancelled withsession/cancelis answered with stop reasoncancelledinstead, as ACP v1 requires of a cancelled prompt.$/cancel_requestis handled by the session and never reaches notification handlers; on the client it is not queued behind slowsession/updateconsumers. - Internal cancellation: a handler that fails with
CancellationException, an interrupt, or anAcpProtocolExceptionwith code-32800is answered-32800(a prompt being cancelled: stop reasoncancelled). Closing a session cancels the requests it is still handling and answers them-32800where the transport still delivers. - Over Streamable HTTP,
$/cancel_requestis connection-scoped both ways, like every$/method and as in the Rust SDK: the client posts it withoutAcp-Session-Id, the agent sends it on the connection stream. It can therefore overtake a request still on a session stream; the receiver then ignores it as an unknown id (the spec's MAY) and the request completes. - Params of a received
$/cancel_requestwithout a string or integerrequestIdare ignored (a notification has no response to carry-32602). AcpSchema.METHOD_CANCEL_REQUEST,AcpSchema.CancelRequestNotification,RequestCancellation,AcpSchedulers.timeoutDelivery().- Cross-SDK suite: the Java programs run
cancel-request.client,cancel-request.agentandcancel-request.unknown; theirP2expected failures are gone.
- Cancelling a request you sent: dispose (cancel) the subscription to its
-
Prompt timeouts on the agent builders (
AcpAgent.async(...),AcpAgent.sync(...)andAcpAgentSupport.builder()), also asPromptTimeoutson theAcpAgentSessionconstructor:cancelGracePeriod(Duration): how long a prompt handler has to answer aftersession/cancelbefore the agent answerscancelleditself. Default 60 seconds (see Changed).maxPromptDuration(Duration): how long a prompt may run at all. When it passes, the agent cancels the handler and answers with JSON-RPC error-32800(request cancelled), messagePrompt exceeded maxPromptDuration of <duration>and data{"maxPromptDuration": "<ISO-8601>"}(ACP v1, Cancellation: an internally cancelled request, an internal timeout included, answers-32800); a prompt already being cancelled is answeredcancelledinstead. Default: none, as a prompt turn can legitimately run for a long time.
Duration.ZEROturns either off; a negative or null duration is rejected. Either way the forced answer leaves through the normal path: the turn ends just before it is published, it follows every update the handler had already sent, and exactly one answer is sent when the handler answers just as a timer fires (model checked with Lincheck,PromptAnswerLincheckTest). The timers run on the SDK's shared timeout timer, with no new threads. Cancelling a sync handler interrupts its thread (the SDK's sync handler scheduler interrupts on cancel); a handler that ignores the interrupt, or any handler that keeps running after its subscription is cancelled, has the updates it then sends through its prompt context dropped (see Fixed).AcpErrorCodes.REQUEST_CANCELLED(-32800) is new. -
session_info_update(AcpSchema.SessionInfoUpdate): the agent tells the client the session's title and last activity time. Stable in ACP v1. Known limit: the schema lets a peer sendnullto clear a field; the record reads an explicitnulllike a missing field and never writes one, so it cannot express a clear. -
configOptionsonNewSessionResponse,LoadSessionResponseandResumeSessionResponse(ACP v1 schema; model selection moved to config options whensession/set_modelwas removed). An agent advertises its config options and their current values when the session is created, loaded or resumed, and the client reads them from the response. New constructorsNewSessionResponse(sessionId, modes, configOptions)andLoadSessionResponse(modes, configOptions)/ResumeSessionResponse(modes, configOptions). -
Tool call
nameonToolCall,ToolCallUpdateNotificationandToolCallUpdate(stable in ACP v1 since 2026-09-17): the programmatic name of the tool being invoked, next to the human-readabletitle. Optional; absent andnullboth mean no name. -
Terminal authentication (stable in ACP v1 since 2026-08-20): the
terminalauth method (AcpSchema.AuthMethodTerminal, with the extraargsandenvthe client runs the agent program with for an interactive login) and the client capabilityauth.terminal(AcpSchema.AuthCapabilitiesonClientCapabilities.auth). Agents check it withNegotiatedCapabilities.supportsTerminalAuth()/requireTerminalAuth(). -
Boolean config options are stable (ACP v1 since 2026-07-06):
SessionConfigBooleanis no longer@UnstableAcpApi. The client capabilitysession.configOptions.boolean(ClientCapabilities.session,ClientSessionCapabilities,SessionConfigOptionsCapabilities,BooleanConfigOptionCapabilities;SessionConfigOptionsCapabilities.withBoolean()) tells the agent it may send them; agents checkNegotiatedCapabilities.supportsBooleanConfigOptions(). -
Building a model picker and other categorized config options. Model choice is a select config option with
category: "model", and before only the 8-argument canonicalSessionConfigSelectconstructor took a category. New:SessionConfigSelect.model(id, name, currentValue, options), withoptionsaList<SessionConfigSelectOption>or a (grouped)SessionConfigSelectOptions: a select option with category"model".SessionConfigSelect.builder():id,name,description,category,currentValue,options(List)(flat),groups(List<SessionConfigSelectGroup>),options(SessionConfigSelectOptions)andmeta, thenbuild().SessionConfigBoolean.builder():id,name,description,category,currentValue(boolean)andmeta, thenbuild().AcpSchema.SessionConfigOptionCategory: the categories ACP v1 reserves, asStringconstantsMODE,MODEL,MODEL_CONFIGandTHOUGHT_LEVEL.categorystays an openString; custom categories start with_.
build()throwsIllegalStateExceptionnaming the first missing required field (id,name,currentValue, and for a select the options). -
ClientCapabilities.builder()andAgentCapabilities.builder()reach every field without positionalnulls. Advertising boolean config options was the 6-argument canonical constructor with threenulls; it is nowClientCapabilities.builder().session(ClientSessionCapabilities.withBooleanConfigOptions()).build().ClientCapabilities.Builder:fs,terminal,session,auth,elicitation,meta. It starts fromnew ClientCapabilities()(file systemfalse/false, terminalfalse).AgentCapabilities.Builder:loadSession,sessionCapabilities,mcpCapabilities,promptCapabilities,auth,providers(@UnstableAcpApi),meta. It starts fromnew AgentCapabilities()(nosession/load, default MCP and prompt capabilities).ClientSessionCapabilities.withBooleanConfigOptions():{"configOptions": {"boolean": {}}}.
-
Agent capability
auth.logout(AgentCapabilities.auth,AgentAuthCapabilities,LogoutCapabilities;AgentAuthCapabilities.withLogout()): the agent advertises that it supportslogout, and clients checkNegotiatedCapabilities.supportsLogout()/requireLogout()before calling it. -
_metaon every record the ACP schema gives it. 48 records lacked it, among themRequestPermissionRequest/Response, thefs/*andterminal/*requests and responses,AuthenticateRequest/Response,SetSessionModeRequest/Response,Implementation, the MCP server records,PlanEntry,AvailableCommand,ToolCallUpdate, the tool call content records,Annotationsand the capability records. Each gains a nullablemetacomponent ("_meta"on the wire) as its last component, and keeps a constructor with its previous components, so existing calls compile; the exception isToolCallUpdate(nine components before_meta), which instead gainsToolCallUpdate(toolCallId, title, kind, status)for the common permission-request case (migration: use it, or addnullfor_meta). A test walks a copy of the v1.9.1 schema (acp-core/src/test/resources/schema/v1/schema.json) and fails on any object with_metawhose record lacks it; the elicitation records are covered too (below). Not covered yet: the presence markers typedObject(they keep_metaas a map entry).CancelRequestNotificationcarries_meta(with$/cancel_request, below). -
elicitation/completereaches a typed client handler:AcpClient.async(...) .completeElicitationHandler(Function<CompleteElicitationNotification, Mono<Void>>)andAcpClient.sync(...).completeElicitationHandler(Consumer<CompleteElicitationNotification>). Before, the agent could send the notification but a client could only read it as raw params throughnotificationHandler. The spec requires clients to ignore unknown or already-completed elicitation IDs; the handler sees every notification and does that check. -
AcpSyncAgent.createElicitationandAcpSyncAgent.completeElicitation, matching the async agent. -
CreateElicitationResponse.accept()without content (for an accepted URL elicitation), and the mode constantsCreateElicitationRequest.MODE_FORMandMODE_URL. -
The elicitation records carry every field of the stable schema:
_metaonElicitationSchema, the five property schemas, both multi-select item types andEnumOption, anddescriptiononEnumOption. The constructors without them remain, except for the two that would take seven or more arguments. -
Custom extension methods (
_-prefixed), both directions, on all four APIs. ACP v1 (Extensibility) reserves method names that start with_for custom requests and notifications.- Agents can now serve them:
AcpAgent.async(..)andAcpAgent.sync(..)builders takeextRequestHandler(method, paramsType, handler)andextNotificationHandler(method, paramsType, handler), with aTypeReffor typed params, or without one for the raw JSON value (Map,List,String,Number,Boolean). Annotation-driven agents use@ExtRequest("_name")and@ExtNotification("_name")(acp-annotations); the method's one parameter, if any, receives the params read as its type. - Both sides can now send them:
AcpAsyncAgent,AcpSyncAgent,AcpAsyncClientandAcpSyncClienthavesendExtRequest(method, params, resultType),sendExtRequest(method, params)(raw result) andsendExtNotification(method, params). A"result": nullanswer completes the async send empty and makes the sync send returnnull. - Every one of these methods rejects a name that does not start with
_withIllegalArgumentException, so a custom handler cannot replace a protocol method and a custom send cannot impersonate one.ExtensionMethods(spec) holds the rule. The client's untypedrequestHandler(method, ..)andnotificationHandler(method, ..), which take any name, are unchanged. - Unhandled ones follow the spec, on both sides: a request is answered with
-32601(Method not found), a notification is ignored. A handler that produces no result answers with-32603; return an empty map when there is nothing to return.
- Agents can now serve them:
-
TypeRef.of(Type): a type reference for a type known only at runtime. -
StreamableHttpAcpAgentTransportlistens on an ephemeral port when given port 0. Before, the constructor rejected 0 (Port must be positive), so a test or an embedding application had to reserve a port and race to rebind it. A port outside 0–65535 is rejected.getPort()returns the configured port (0 for an ephemeral one) untilstart()completes, then the port actually bound, which it keeps reporting after the listener closes (before, Jetty's negative "not open" value). -
AcpAgentSupport.Builder.buildFactory(): anAcpAgentFactoryfor a listener transport (StreamableHttpAcpAgentTransport,StreamableHttpAcpServlet) that creates a fresh agent per connection from one annotated handler bean. The bean is shared, like a Spring controller: every connection's agent invokes the same instance, concurrently, so its handlers (and interceptors, custom resolvers and return value handlers) must be thread-safe. Before, the documented pattern rebuilt the builder, and rediscovered the handler methods, per connection. -
@Authenticate: the annotation for anauthenticatehandler, which an annotated agent could not serve before (the client got-32601, Method not found). The method takes an optionalAuthenticateRequest(AuthenticateRequestResolver) and returns anAuthenticateResponseor aMonoof one; throwAcpProtocolExceptionwithAcpErrorCodes.AUTHENTICATION_REQUIREDto reject the attempt. -
Any annotated handler can take its connection's agent: an
AcpSyncAgentorAcpAsyncAgentparameter (AgentResolver,AcpInvocationContext.getAgent()), the agent serving the connection the request arrived on, so a handler can send session updates (aConfigOptionUpdate, say) and requests to the client outside a prompt turn. UnderbuildFactory()one handler bean serves every connection and so cannot keep "its" agent in a field; each call now gets its own connection's. Extension handlers (@ExtRequest,@ExtNotification) may take these andNegotiatedCapabilitiesbesides their one params parameter (ExtensionParamsResolver.isConnectionType). -
Shortcuts for the messages real code writes most:
new NewSessionRequest(cwd)(no MCP servers),new NewSessionResponse(sessionId)(no modes or config options) andPromptRequest.text(sessionId, text)(a prompt of one text block). Each equals, and writes the same JSON as, the long form. -
defaultSessionUpdateHandler(..)onAcpClient.AsyncSpecandSyncSpec: a session update handler used only while nosessionUpdateHandleris added, so a framework can set a default (such as logging each update at DEBUG) that the application's own handler replaces, whichever is registered first. The Spring Boot autoconfiguration and the Micronaut and Quarkus client beans now set their DEBUG-logging handler this way; it used to run beside every handler a customizer added.sessionUpdateHandlerstays additive. (Snapshots before the rename below called thesedefaultSessionUpdateConsumerandsessionUpdateConsumer.)
-
AcpWsOutbound.closereturns aCompletionStage<Void>(@UnstableAcpApi), which completes once the close frame has been sent or the socket has closed; a graceful shutdown waits for it, bounded by the shutdown timeout. Migration: a host of its own returns the stage its container gives (WebFluxsession.close, Vert.xServerWebSocket.close), or one that completes when the container reports the socket closed (JakartaEndpoint.onClose). -
Spring Boot: a reactive (WebFlux) web application no longer fails the startup with "The ACP HTTP transport needs a servlet web application or the standalone listener (acp-streamable-http-jetty); WebFlux is not supported". It serves the endpoint with
acp-http-webflux(see Added). Without that module the startup still fails, now naming it: "The ACP HTTP transport in a reactive (WebFlux) web application needs acp-http-webflux on the classpath". The error fortype=httpwith no HTTP module at all namesacp-http-webfluxtoo. Migration: a reactive application that serves ACP addscom.agentclientprotocol:acp-http-webflux. -
Breaking: Quarkus serves ACP on a Vert.x route, without a servlet container. The extension depends on
quarkus-vertx-httpinstead ofquarkus-undertow, and mounts the SDK'sAcpHttpEndpointon the Quarkus router through a host of its own (AcpVertxHost); the servlet and the separate WebSocket route (AcpHttpServlet,AcpWebSocketRoute,VertxWebSocketConnection, the runtime beanAcpHttpEndpoint) are gone. Migration: the path is underquarkus.http.root-path, no longer underquarkus.servlet.context-path; an application that relied on the extension to bringquarkus-undertowadds it itself. -
Breaking: the HTTP transport is split into three modules.
acp-http-core(the endpoint andStreamableHttpAcpAgentTransportOptions),acp-http-servlet(StreamableHttpAcpServlet) andacp-streamable-http-jetty(StreamableHttpAcpAgentTransport, now embedded Jetty running the servlet with Jetty's Jakarta WebSocket implementation). Class names and packages of the public types are unchanged. Migration: an application mounting the servlet in its own container, a Spring MVC application included, depends onacp-http-servletinstead ofacp-streamable-http-jetty, and no longer gets Jetty; an application using the SDK's listener keepsacp-streamable-http-jetty, which now brings the Jakarta WebSocket 2.2 API (Jetty 12.1 runs on it) rather than Jetty's 2.1, so it can no longer shadow Tomcat 11's. A Spring Boot servlet web application on Tomcat whose classpath still puts an older Jakarta WebSocket API ahead of Tomcat's fails at startup with a message naming the fix (acp-http-servlet, or managingjakarta.websocket-apito 2.2.0), where before Tomcat failed every WebSocket send withNoSuchMethodErrorand clients hung. -
Breaking:
StreamableHttpAcpServletis a host ofAcpHttpEndpoint. It gained the constructorStreamableHttpAcpServlet(AcpHttpEndpoint)andendpoint(); the package-private routing, connection and SSE classes moved toacp-http-core. A subclass overridingdoGet,doPostordoDeleteno longer intercepts anything:servicehands every request to the endpoint. Migration: wrap theAcpHttpEndpointinstead. -
Breaking: the shutdown close code of a WebSocket is 1001 (going away), not 1000. Clients that treated 1000 as the server's shutdown treat 1001 the same way.
-
AcpListeners.endpoint(AcpAgentSettings, AcpAgentFactory)returns the endpoint for a framework that mounts it itself. -
Breaking: new record components for the bind address and allowed origins.
StreamableHttpAcpAgentTransportOptionsgainedhostandallowedOrigins,AcpAgentSettings.HttpgainedallowedOrigins, andAcpAgentSettings.Listenergainedhost(its first component). Migration: build the options withStreamableHttpAcpAgentTransportOptions.builder()and the settings withAcpAgentSettings.builder()rather than the canonical constructors; a directnew AcpAgentSettings.Listener(port, streams)becomesnew Listener(null, port, streams). -
Quarkus: new
quarkus.acp.handler-executor=managed|virtual; the client bean's network transports run on it. The default,managed, keeps the agent's handler methods on theManagedExecutor(theexecutor-thread-*worker pool), as before, with the MicroProfile Context Propagation contexts it carries; the client bean's WebSocket and Streamable HTTP transports now run on the same executor and create no pool of their own.virtualopts in to Quarkus' own virtual-thread executor (@VirtualThreads,quarkus-virtual-thread-*) on JDK 21 and later (asmanagedbefore), following Quarkus, where virtual threads are opt-in. Whatvirtualdoes not carry: the contexts of the submitting thread. A handler has no CDI request context of the caller's (a@RequestScopedbean needs@ActivateRequestContexton the handler method), and no propagated security identity, transaction or OpenTelemetry context; the Vert.x context is kept. JDK 21 to 23 pin a virtual thread's carrier when it blocks insidesynchronized(fixed in JDK 24, JEP 491). -
Micronaut: the listener and the client transports run on
TaskExecutors.VIRTUALtoo. On JDK 21 and later the HTTP/WebSocket listener and the auto-configured WebSocket and Streamable HTTP client transports run on Micronaut's virtual-thread executor, as the handlers already did, and create no pool of their own; before JDK 21 they keep the SDK's pools. Migration: none. -
Spring Boot follows its own virtual-thread opt-in for every ACP thread. With
spring.threads.virtual.enabled=true(JDK 21 and later), the agent's handlers (as before), the SDK's listener and the auto-configured WebSocket and Streamable HTTP client transports all run on theapplicationTaskExecutor, so the SDK creates no pool of its own. Without it, they all stay on platform threads on every JDK: the handlers on the SDK's pool (acp-agent-sync-handler), the listener on Jetty's pool and the client transports on their platform pools, rather than taking the SDK's new virtual-thread default. The virtual-thread check is now Boot's own (Threading.VIRTUAL), so the property set on JDK 17 no longer hands the handlers Boot's 8-threadapplicationTaskExecutor. Migration: none; setspring.threads.virtual.enabled=truefor virtual threads. -
The network transports use virtual threads by default on JDK 21 and later. Without an executor, the Streamable HTTP client runs its work on a virtual thread per task (
acp-streamable-http) instead of its three bounded pools (acp-streamable-http-client,-signal,-sse); the WebSocket client runs itsHttpClientand its writer on virtual threads (acp-ws-client) instead of a cached pool and theacp-ws-client-outboundthread; and the listener serves on Jetty'sVirtualThreadPool(acp-listener-*) with its timer on a virtual thread, instead ofqtp*andScheduler-*platform threads. The choice is made at run time; JDK 17 keeps the pools.httpWorkerThreads,httpSignalThreadsandhttpQueueCapacitynow size only those pools, andmaxSseStreamsstill bounds the streams. The same JDK 21 to 23synchronizedpinning caveat applies (fixed in JDK 24, JEP 491). Migration: none; setvirtualThreads(false)on either options builder to keep the platform pools on JDK 21. -
The servlet's SSE keep-alive no longer has a thread of its own. Each
StreamableHttpAcpServlet(and so each listener) started a daemon thread (acp-streamable-http-keepalive) for its keep-alive comments; they are now timed on the SDK's shared timer and written off it. Migration: none. -
StreamableHttpAcpAgentTransportOptions' canonical constructor takes two more components,executorandvirtualThreads, and the client options' canonical constructor takes them too; the client options keep their four-argument constructor. Migration: build the listener options withStreamableHttpAcpAgentTransportOptions.builder(), or passnull, truefor the new components. -
Synchronous handlers run on virtual threads by default on JDK 21 and later. A sync agent or client built without
handlerExecutor(..)(andAcpAgentSupportwithout one) ran its handlers on a cached pool of daemon platform threads (acp-agent-sync-handler,acp-sync-handler). Where the JDK has virtual threads, each handler call now runs on a virtual thread of its own, with the same name; the choice is made at run time, so the Java 17 artifacts are unchanged and JDK 17 keeps the pool. Caveat: on JDK 21 to 23 a virtual thread that blocks inside asynchronizedblock pins its carrier thread, and enough pinned handlers can stall every other virtual thread; JDK 24 removed that (JEP 491). Migration: none for most handlers; one that blocks insidesynchronizedon JDK 21 to 23 can switch to aReentrantLock, or pass a platform pool tohandlerExecutor(..)to keep the old behaviour. -
Timeout work runs on virtual threads on JDK 21 and later. What a request timeout, the stdio drain timer or a
$/cancel_requesttriggers used to run on a cached pool of daemon platform threads (acp-timeout-delivery); where the JDK has virtual threads it now runs on a virtual thread per task, so no such pool exists. The shared timer itself (acp-timeout) stays one daemon platform thread per JVM, so that handlers pinning every carrier thread cannot also stop the timeouts meant to end them. On JDK 17 nothing changes. Migration: none. -
The client advertises the capabilities its handlers serve, as the agent side does. A client builder without
clientCapabilities(..)now advertisesfs.readTextFileandfs.writeTextFilefor their handlers,terminalonce all five terminal handlers are registered (some but not all log a warning naming the missing ones and advertise no terminal), and form-mode elicitation for acreateElicitationHandler. Before, it advertised nothing and only warned, so an SDK agent refused to call the handlers. Merge rule: explicit wins. Capabilities set withclientCapabilities(..)are sent as they are, nothing is derived, and the existing check applies (an advertised capability without its handler failsbuild(); a handler without its capability is a warning). Explicit capabilities are the one source of truth whenever they are given, so the frameworks, which always pass the capabilities their settings name, advertise exactly what the settings say, as before: a handler a customizer adds never widens them. An OR of the two would let a handler silently advertise what a setting turned off. Set capabilities explicitly for what handlers cannot express: URL-mode elicitation, boolean config options, terminal authentication,_meta. Migration: none for code that setsclientCapabilities(..). A client that registered handlers without capabilities on purpose, so that agents would not call them, passesclientCapabilities(new ClientCapabilities()). -
Breaking: the prompt context is split into its two layers.
PromptContextandSyncPromptContextkeep the convenience layer —sendMessage,sendThought,sendSessionUpdate,readFile,tryReadFile(sync),writeFile,askPermission,askChoice,execute, cancellation (isCancelled,whenCancelled/onCancel),getSessionId,getClientCapabilitiesandasync()(sync) — and the raw ACP requests move one step down to the newcontext.client(): aSessionClient(async,Monos) orSyncSessionClient(blocking) withreadTextFile,writeTextFile,requestPermission,createTerminal,getTerminalOutput,releaseTerminal,waitForTerminalExit,killTerminal,createElicitationandcompleteElicitation. The calls themselves are unchanged (same request records, capability checks, timeouts and cancellation).readFileno longer sits besidereadTextFile, noraskPermissionbesiderequestPermission, in the completion menu. Migration: insert.client()before each raw call —context.readTextFile(request)→context.client().readTextFile(request),context.writeTextFile(request)→context.client().writeTextFile(request),context.requestPermission(request)→context.client().requestPermission(request),context.createTerminal(request)→context.client().createTerminal(request)(likewisegetTerminalOutput,releaseTerminal,waitForTerminalExit,killTerminal),context.createElicitation(request)→context.client().createElicitation(request),context.completeElicitation(notification)→context.client().completeElicitation(notification). A test double implementingPromptContextorSyncPromptContextdrops those ten methods and implementsclient(). -
Breaking:
AcpAgent.AsyncAgentBuilderandAcpAgent.SyncAgentBuilderarefinal. Their constructors were already package-private, so no code outside the SDK could extend them; the modifier makes that explicit and lets the builders grow overloads safely. Migration: none; build agents withAcpAgent.async(transport)andAcpAgent.sync(transport), and wrap a builder rather than subclass it. -
Breaking: one vocabulary for session updates. The client builders' setters are handlers, like every other setter, and the prompt context sends session updates with the same verb as the agent and the test kit (
AcpAsyncAgent.sendSessionUpdate,AcpSyncAgent.sendSessionUpdate,MockAcpAgent.sendSessionUpdate). Renamed outright, without deprecated aliases:AcpClient.AsyncSpec/SyncSpec.sessionUpdateConsumer(..)→sessionUpdateHandler(..),defaultSessionUpdateConsumer(..)(snapshots only) →defaultSessionUpdateHandler(..), andPromptContext.sendUpdate(update)/SyncPromptContext.sendUpdate(update)→sendSessionUpdate(update). Behaviour and parameter types are unchanged, and so is the registration error a rawnotificationHandler("session/update", ..)gets, which now namessessionUpdateHandler. Migration: rename the calls —.sessionUpdateConsumer(n -> ...)→.sessionUpdateHandler(n -> ...),.defaultSessionUpdateConsumer(n -> ...)→.defaultSessionUpdateHandler(n -> ...),context.sendUpdate(update)→context.sendSessionUpdate(update). -
SessionCapabilities' six- and four-argument constructors are@UnstableAcpApi. Both take the unstableforkcomponent (the four-argument one exists only to set it) but were not marked, so stable code could reach an unstable capability without a marker.UnstableApiMarkerTestnow also checks constructors that set an unstable component. Migration: none; code that must avoid unstable API setsadditionalDirectoriesthrough the canonical constructor or a builder answer. -
Breaking: the client sends
additionalDirectoriesonly to an agent that advertises them, and an annotated agent can advertise them. ACP lets a client send additional workspace directories only when the agent advertisessessionCapabilities.additionalDirectories, butnewSession,loadSession,resumeSessionandforkSessionsent them to any agent. A call whose request names a non-emptyadditionalDirectoriesnow fails withAcpCapabilityException(capabilitysessionCapabilities.additionalDirectories) without being sent, as other capability-gated calls do; no list or an empty one needs nothing. On the agent side,@AcpAgent(additionalDirectories = true)advertises the capability; it needs a@NewSessionmethod, since the defaultsession/newanswer would drop the directories, which ACP forbids. A builder agent advertises it from itsinitializeHandler. Migration: a client checkgetAgentCapabilities().supportsAdditionalDirectories()before naming directories; an annotated agent that uses them addadditionalDirectories = trueto@AcpAgent(an@Initializemethod that advertised them keeps working). -
AcpAgentSupport.Builder.cancelGracePeriod(..)andmaxPromptDuration(..)reject a negative or null value where it is set. They stored any value:build()failed later, andbuildFactory()built a factory whose every connection then failed to build its agent. Both now throwIllegalArgumentExceptionat once. Migration: none for valid values. -
Breaking:
AcpInterceptor.afterCompletionreceives the exception the call failed with. It took only the context, and the runtime dropped the exception it had, so cleanup that must know how a call ended (a span status, a failure metric) had to stash the exception fromonErrorin an attribute. The method is nowafterCompletion(AcpInvocationContext context, Throwable ex):exis what the call failed with (what a step threw, or whatonErrorthrew in its place), andnullwhen it produced a result, whenonErrorreturned a replacement, and when apreInvokereturnedfalse. Migration: add theThrowableparameter to anafterCompletionoverride. An override without@Overridekeeps compiling but is no longer called, so add@Override. -
The default
session/newanswer passes through the interceptors. An annotated agent without a@NewSessionmethod answerssession/newwith a random session id; that answer skipped the interceptors, while theinitializeanswer derived from the annotations went through them, so a logging or access-check interceptor missed session creation. It now passes through the chain like any handler method:preInvokecan veto it or reject it with anAcpProtocolException. Migration: an interceptor that should not act onsession/newcheckscontext.getAcpMethod(). -
Breaking: an
@ExtNotificationmethod must returnvoid. A notification gets no answer, and a value the method returned was dropped without a word, while avoid@ExtRequestwas already rejected. Building the agent now rejects a non-void@ExtNotificationmethod with anIllegalStateExceptionnaming the method. Migration: returnvoid, or make it an@ExtRequestif the client expects an answer. -
An extension notification without a handler is logged at DEBUG, not WARN. ACP asks implementations to ignore notifications they do not recognize, and a peer can send many (
_auth/status_updatefrom some agents), each of which logged a warning on both sides. A_-prefixed notification without a handler is now logged at DEBUG; a protocol notification without a handler still logs a warning. Migration: none; raise the session logger to DEBUG to see them. -
Breaking:
AcpErrorextendsAcpException. It extendedRuntimeExceptiondirectly, socatch (AcpException e), which the SDK documented as the one place to handle its failures, missed every error the peer answered with.AcpExceptionis now the base of every failure of a call:AcpError,AcpProtocolException,AcpCapabilityException,AcpConnectionExceptionandAcpTimeoutException.AcpErrorkeeps its package, constructor, methods and messages. Migration: a multi-catchcatch (AcpException | AcpError e)no longer compiles; catchAcpExceptionalone. Acatch (AcpException e)placed before acatch (AcpError e)now takes the peer's errors too; put theAcpErrorclause first. -
Breaking: a lost connection fails a request with
AcpConnectionException, on both sides. A request still waiting when the transport ended failed with a plainRuntimeException("ACP session with agent terminated"), and a request or notification sent after that with anIllegalStateException("ACP client transport is not connected: ..." or "ACP agent transport is not started: ..."), socatch (AcpConnectionException e)missed the commonest connection failure, a peer that went away. Both are nowAcpConnectionException, with the same messages and the transport's failure as the cause; requests waiting when a session is closed fail the same way. Building a client or agent on a transport that refuses at once (one already in use) still throwsIllegalStateException. Migration: replacecatch (IllegalStateException e)orcatch (RuntimeException e)around calls that meant "the connection is gone" withcatch (AcpConnectionException e). -
Breaking: a peer's error that escapes a handler is passed on unchanged. A handler that called the other side and let the resulting
AcpErrorescape answered its own request-32603, losing the peer's code (a-32601or-32002became an internal error). TheAcpError's JSON-RPC error, code, message and data, is now the answer, so a handler with nothing to add can let the failure propagate;getError().toException()is no longer needed for that. Migration: a handler that relied on such a failure being reported as-32603catches theAcpErrorand throwsAcpProtocolExceptionwith the code it wants. -
Breaking: an interceptor's
onErrorcan answer with an error of its own, and reaches only the interceptors that saw the call. WhatonErrorthrew used to be logged and ignored, so the client got the original failure, and the migration note for the removed@AcpExceptionHandler(handle the exception inonErrorand throwAcpProtocolException) did not work. Now anAcpProtocolExceptionthrown fromonErroris the answer, with its code, message and data; any other exception thrown there is a fault in the interceptor and is answered-32603. Either ends the walk, and the original failure is attached as suppressed.onErrorwas also called on every interceptor, including those whosepreInvokenever ran or threw; it now reaches exactly the interceptors that getafterCompletion, those whosepreInvokereturnedtrue. Migration: anonErrorthat throws expecting to be ignored should log and returnnullinstead; one that relied on seeing failures from apreInvokethat did not pass should move that handling into itspreInvoke. -
A supported Jackson floor, checked at startup: Jackson 2.18.1 or later for
acp-json-jackson2, Jackson 3.0.0 or later foracp-json-jackson3. Frameworks manage their own Jackson version, and the SDK stated none: an older Jackson (Vert.x 4.5's jackson-core 2.16.1 under jackson-databind 2.22.3, for example) failed much later with aNoSuchMethodErrorwhile reading a message. Each mapper supplier (JacksonAcpJsonMapperSupplier,Jackson3AcpJsonMapperSupplier) and mapper constructor now checks the jackson-core and jackson-databind on the classpath and fails with anIllegalStateExceptionnaming the version found and the version required. The floors are the oldest releases the SDK's tests pass with (2.18.0 drops unknown values of the open enumerations, and 2.17 the unknown fields the SDK keeps for forward compatibility). Quarkus 3.40 (Jackson 2.21.7) and Spring Boot 4.1 (Jackson 3.1.7) are within them. Migration: none for a consistent Jackson at or above the floor; otherwise align the Jackson artifacts on one version, for example by importing thejackson-bom. -
Breaking:
StdioAcpClientTransport.awaitForExit()is renamedawaitProcessExit(), so it does not sit besideawaitTermination()(which completes when the transport ends, not the process). Interrupted, it now throwsCancellationExceptionand keeps the thread's interrupt flag; it used to throw a plainRuntimeExceptionand clear the flag. Migration: rename the call; catchCancellationExceptioninstead ofRuntimeExceptionaround it. -
Breaking: sync lifecycle names line up.
AcpSyncAgent.await()is renamedawaitTermination(), matchingAcpAsyncAgentand the transports.AcpSyncAgentandAcpAgentSupportimplementAutoCloseable, and theirclose()now means what it means onAcpSyncClient: close gracefully, waiting at most 10 seconds, then close at once if that failed or took longer (AcpSyncAgent.close()used to close at once;AcpAgentSupport.close()used to wait up to the 5-minute block timeout and never force). NewAcpSyncClient.async()returns theAcpAsyncClientit blocks on. Migration: replaceagent.await()withagent.awaitTermination(); for the old immediate close, callagent.async().close(); use try-with-resources on the sync agent and the annotated agent. -
Breaking:
PromptContext.sendSessionUpdate(update)andSyncPromptContext.sendSessionUpdate(update)replace the two-argumentsendUpdate(sessionId, update). The context belongs to one prompt's session, so the session ID was redundant (and a wrong one sent the update to another session). Migration: drop the first argument and use the new name,context.sendUpdate(sessionId, update)becomescontext.sendSessionUpdate(update); to update another session, callAcpAsyncAgent.sendSessionUpdate(sessionId, update)(orAcpSyncAgent.sendSessionUpdate). Test doubles implementing either context implement the one-argument method. -
Breaking:
handlerExecutor(ExecutorService)onAcpAgent.SyncAgentBuilderandAcpClient.SyncSpec(andAcpAgentSupport.Builderfor annotated agents); the static handler pools and the agent's default timeout are no longer public constants. Sync handlers (and a sync client's session update consumers) ran on static, unbounded, JVM-wide cached pools exposed asAcpAgent.SYNC_HANDLER_SCHEDULERandAcpClient.SYNC_HANDLER_SCHEDULER(ReactorSchedulers on the entry-point interfaces). Pass an executor of your own, for exampleExecutors.newVirtualThreadPerTaskExecutor(), a Quarkus worker pool or Micronaut'sTaskExecutors.BLOCKINGexecutor; the SDK cancels a handler by cancelling its task (interrupting the thread) and never shuts the executor down. Without one, handlers keep running on the same daemon pools as before.AcpAgent.DEFAULT_REQUEST_TIMEOUTis removed too; the default (60 seconds) is stated onrequestTimeout. Migration: drop references toAcpAgent.SYNC_HANDLER_SCHEDULER/AcpClient.SYNC_HANDLER_SCHEDULER(use your own scheduler, orhandlerExecutor(...)to choose where handlers run) and replaceAcpAgent.DEFAULT_REQUEST_TIMEOUTwithDuration.ofSeconds(60). -
Breaking: the agent builders reject a null handler and a second handler for the same method. The typed setters of
AcpAgent.AsyncAgentBuilderandAcpAgent.SyncAgentBuilderacceptednull(every request for that method was then answered-32603), and registering a method again silently replaced its handler. A null handler now fails withIllegalArgumentExceptionat the setter, and a second registration of the same ACP method (or extension method) fails withIllegalStateExceptionnaming the setter, for example "A handler for session/prompt is already registered; promptHandler was called twice on this builder". Migration: register each handler once; to choose between handlers, decide before calling the setter. -
askPermissionandaskChoiceask about a tool call the client knows; newaskPermission(String action, ToolKind kind)onPromptContextandSyncPromptContext. The permission request named a tool call ID that was never announced (clients that look it up in their tool-call list found nothing), andaskPermissionlabelled every actionedit. Both helpers now announce the tool call first with atool_callsession update (statuspending), ask about it, and settle it with atool_call_update(completedonce the user answered,failedif the client cancelled the request).askPermission(action)uses kindother; pass a kind with the new overload, for exampleaskPermission("Run the tests", ToolKind.EXECUTE). Breaking (behaviour): the session update consumers now see two more updates per call, and a client that showed these as edits shows them asother. Migration: none needed; to keep the old kind, callaskPermission(action, ToolKind.EDIT). Implementations ofPromptContext/SyncPromptContext(test doubles) add the new overload. -
Breaking: the sync API throws
AcpTimeoutExceptionfor a timeout, andCancellationExceptionwhen interrupted, instead of Reactor's internalExceptions$ReactiveException. Every blocking method ofAcpSyncClient,AcpSyncAgentandSyncPromptContextlet a request timeout escape asreactor.core.Exceptions$ReactiveException(causeTimeoutException), andAcpSyncAgent's block timeout asIllegalStateException("Timeout on blocking read ..."). Both now throw the newcom.agentclientprotocol.sdk.error.AcpTimeoutException(anAcpException), whose cause is theTimeoutException. A blocked call whose thread is interrupted (the SDK interrupts a sync handler to cancel it) throwsjava.util.concurrent.CancellationExceptionand leaves the interrupt flag set; any other checked cause is thrown as anAcpException. The asynchronous API is unchanged (itsMonofails with theTimeoutException). Migration: replacecatch (RuntimeException e) { if (e.getCause() instanceof TimeoutException) ... }(orExceptions.unwrap(e)) withcatch (AcpTimeoutException e), and catchCancellationExceptionwhere you handled an interrupt. -
Breaking (behaviour): a client's prompt is no longer bounded by its request timeout; new
promptTimeout(Duration)onAcpClient.AsyncSpecandAcpClient.SyncSpec, default none. A prompt's answer comes only at the end of its turn, so the 30-second request timeout cancelled every turn that ran longer (the client failed the call with aTimeoutExceptionand sent the agent$/cancel_request, which ended the turn).session/promptnow waits for the end of the turn however long it takes; every other request keeps the request timeout. Disposing the prompt'sMono(or interrupting a blockedAcpSyncClient.prompt) still cancels it. Migration: to keep a bound on turns, set.promptTimeout(Duration.ofMinutes(10))(any positive duration; it fails the call with aTimeoutExceptionand sends$/cancel_request, as before); code that raisedrequestTimeoutonly to let long turns finish can drop it. In Spring Boot, setspring.acp.client.prompt-timeout, in Micronautacp.client.prompt-timeout, in Quarkusquarkus.acp.client.prompt-timeout(unset by default). -
Breaking: an annotated agent with
@SetSessionModeor@SetSessionConfigOptionneeds a@NewSessionmethod. The defaultsession/newanswers with a random session id and no modes or config options, so a client never saw any to set and never called those methods. Modes and config options are values the agent chooses per session (their ids, names, current values, often per client capability), which annotations cannot declare, so the SDK cannot fill them into the default answer; building such an agent now fails with anIllegalStateExceptionnaming the setter method and saying "Add a @NewSession method that returns them". Migration: add a@NewSessionmethod returningnew NewSessionResponse(sessionId, modes, configOptions). -
A builder agent answers
initializewithout an initialize handler, and needs a prompt handler.AcpAgent.sync(..)/async(..)agents with noinitializeHandleransweredinitializewith-32601(Method not found), so a prompt-only agent could not even connect. They now answer with the protocol version negotiated with the client and the capabilities their handlers imply (loadSessionHandler→loadSession,listSessionsHandler/closeSessionHandler/resumeSessionHandler/deleteSessionHandler/forkSessionHandler→sessionCapabilities,logoutHandler→auth.logout,listProvidersHandler→providers), as an annotated agent does, and with theagentInfoset by the new builder methodagentInfo(Implementation).build()now throwsIllegalStateExceptionwhen nopromptHandleris registered, and an annotated agent without a@Promptmethod fails to build the same way: every agent must answersession/prompt. Migration: register a prompt handler (test agents that never prompt can answerPromptResponse.endTurn()). -
AcpAgentSupport.Builderrefuses two silent states.build()andbuildFactory()throwIllegalStateExceptionwhen no@AcpAgentbean was given (they used to build an agent with no handlers), andbuildFactory()throws whentransport(..)was set (the transport used to be ignored). Migration: pass the bean withcreate(bean); droptransport(..)beforebuildFactory(). -
Annotation misuse fails when the agent is registered or built, not at the first request.
AcpAgentSupportnow rejects, with a message naming the class, the method and the fix:- at registration (
create(..)/agent(..),IllegalArgumentException): two methods with the same handler annotation (one of them used to win silently), one method with two handler annotations, and a@SessionId,@ConfigIdor@ConfigValueparameter on an@ExtRequestor@ExtNotificationmethod (it used to receive the extension's params, so every call failed-32602); - at
build()/buildFactory()(IllegalStateException): a parameter no argument resolver supplies, the request type of another method,@SessionIdon a method without a session, aPromptContext/SyncPromptContextoutside@Prompt,@ConfigId/@ConfigValueoutside@SetSessionConfigOption, a request handler declaredvoid(other than@Prompt), a return type no return value handler accepts, a return type that cannot give the method's response (including aMonoof another type), and avoid@ExtRequest. Each of these used to fail every call with-32603or-32602. Parameters and return types a customArgumentResolverorReturnValueHandlersupports are not checked. Private and static handler methods remain supported. Migration: a build that now fails names the method to fix.
- at registration (
-
AcpError.getMessage()no longer ends with[code=N]. It is the peer's message, followed by the detail its data carries, soe.getCode() + " " + e.getMessage()names the code once.toString()(what stack traces show) still includes[code=N]. Code that parsed the code out of the message should callgetCode(). -
StreamableHttpAcpAgentTransportOptionsgainsshutdownTimeout(buildershutdownTimeout(Duration), default 5 seconds, positive): how long closing the servlet or the listener waits for its connections' agents before closing the rest at once. Breaking for code calling the record's canonical constructor, which takes it as a new last component; the builder is unaffected. -
0.80.0 is binary-incompatible with 0.18.0: recompile code built against 0.18.0. Source migrates as the Breaking entries below describe, but a jar compiled against 0.18.0 fails at run time, typically with
NoSuchMethodErrororNoClassDefFoundErrorfrom a handler, because several canonical constructors and types changed shape. The main ones:NewSessionResponse,LoadSessionResponse,ResumeSessionResponse:modelsis gone andconfigOptionscomes before_meta, sonew NewSessionResponse(id, modes, null)compiled against 0.18.0 calls a constructor that no longer exists.ToolCall,ToolCallUpdate,ToolCallUpdateNotification(nameaftertitle);ClientCapabilities(session,auth);AgentCapabilities(auth);StringPropertySchema,MultiSelectPropertySchema(canonical constructors only);ElicitationCapabilities(no-arg constructor removed); union records (discriminator component checked).- Types replaced or moved:
AuthMethod(an interface;AuthMethodAgent),ElicitationAction(a record, not an enum),AcpError(onespec.AcpErrorinstead of the session-nested classes),AcpInvocationContextandAcpMethodParameter(agent.support.invocation); thesession/set_modelAPI and theacp-websocket-jettymodule are removed;AcpProtocolExceptionno longer converts to or fromJSONRPCError;SyncPromptContext.askChoicereturnsOptional.
Since this release such an
Errorin a handler is answered-32603instead of leaving the peer waiting (see Fixed), but the handler still fails until it is recompiled. -
Breaking: the short constructors of the session updates no longer take the discriminator. It can only be the variant's own name (or
null), so writing it was noise:new ConfigOptionUpdate(null, options)is nownew ConfigOptionUpdate(options). The new constructors replace the old ones:UserMessageChunk,AgentMessageChunk,AgentThoughtChunk:(content)and(content, messageId).Plan(entries),AvailableCommandsUpdate(availableCommands),CurrentModeUpdate(currentModeId),UsageUpdate(used, size),ConfigOptionUpdate(configOptions)(the full list of options, not only the changed ones).
The canonical constructors (discriminator first,
nullor the variant's name) are unchanged;SessionInfoUpdate(title, updatedAt)already had this shape. Migration: drop the first argument, for examplenew AgentMessageChunk("agent_message_chunk", content)becomesnew AgentMessageChunk(content). -
Breaking: a client's capabilities and info are set only on its builder;
initialize(InitializeRequest)is removed fromAcpAsyncClientandAcpSyncClient. The builder'sclientCapabilities(...)was used only by the no-argumentinitialize(), and the request-taking overload silently replaced it, so a client that set capabilities on the builder and then calledinitialize(new InitializeRequest(1, new ClientCapabilities()))advertised nothing, while its handlers enforced what the builder said. Now:AcpClient.async(...)/AcpClient.sync(...):clientCapabilities(ClientCapabilities)(as before) and the newclientInfo(Implementation).initialize()sends protocol version 1 with the builder's capabilities and client info.initialize(int protocolVersion, Map<String, Object> meta)(new) sends a chosen protocol version and_metawith the same builder values; for_metaand version-negotiation tests, not for capabilities.
Migration: move the request's capabilities to
.clientCapabilities(...)and itsclientInfoto.clientInfo(...), then callinitialize(); pass_metathroughinitialize(1, meta). A client that passednew InitializeRequest(1, null)now advertises the defaultnew ClientCapabilities()(no file system, no terminal) instead of omittingclientCapabilities. -
Quieter logs for errors the caller already receives, and a named logger for agent stderr.
- A peer's JSON-RPC error response (for example a handled
-32602) was logged at ERROR ("Error handling request") and then failed the caller's request with the same error. The caller'sAcpErroris the report; the session now logs it at DEBUG only, asRequest <method> failed with peer error <code>, without the error's message or data (the peer's payload). Applications that relied on the ERROR line should log theAcpErrorthey receive. StdioAcpClientTransportlogs each line the agent writes to its standard error at INFO on its own logger,com.agentclientprotocol.sdk.client.transport.agent-stderr(StdioAcpClientTransport.AGENT_STDERR_LOGGER), asagent: <line>. Before, it used the transport's logger with the prefixSTDERR Message received:. Set that logger to WARN to hide the agent's output, or replace it withsetStdErrorHandler(...).
- A peer's JSON-RPC error response (for example a handled
-
Breaking: error codes follow the ACP v1 schema (
$defs.ErrorCode). A prompt sent while the session already has an active prompt was rejected with-32000, which ACP defines as "Authentication required", so a client could ask its user to log in when the user had only sent a second prompt. It is now rejected with-32600(invalid request), messageThere is already an active prompt on session <id>and data{"sessionId": "<id>"}, as the Rust SDK rejects requests that are invalid in the connection's state; the Kotlin SDK answers-32603.AcpErrorCodeskeeps only codes the schema defines:AUTHENTICATION_REQUIREDis-32000(it was-32004);AcpProtocolException.isAuthenticationRequired()is new.SESSION_NOT_FOUND(-32002) is renamedRESOURCE_NOT_FOUND, the schema's name for the code.CONCURRENT_PROMPT(-32000) andAcpProtocolException.isConcurrentPrompt()are removed; a concurrent prompt is anINVALID_REQUEST.CAPABILITY_NOT_SUPPORTED(-32001),NOT_INITIALIZED(-32003) andPERMISSION_DENIED(-32005) are removed: the SDK made them up inside the range ACP reserves for itself.AcpCapabilityException.toProtocolException(), which answered-32001, is removed (see Removed).
-
Elicitation is stable (the ACP schema promoted it on 2026-07-24, in protocol v1.7.0), and its API is no longer
@UnstableAcpApi:CreateElicitationRequest,CreateElicitationResponse,ElicitationAction,CompleteElicitationNotification,ElicitationSchema, the property schemas, the multi-select items,EnumOption,ElicitationCapabilitiesandClientCapabilities.elicitation. -
Breaking: the elicitation capability's modes are typed, and the no-argument
ElicitationCapabilities()constructor is removed.formandurlwereObject; they are nowElicitationFormCapabilitiesandElicitationUrlCapabilities(each with_meta), as in the schema. The removed constructor advertised form mode, although the spec says an empty capability ({}) advertises no mode. Migration:new ElicitationCapabilities()becomesElicitationCapabilities.formOnly();urlOnly()andformAndUrl()advertise the other modes, and code that readform()orurl()asObjectgets the typed records. -
Breaking:
ElicitationActionis an open value, not an enum, by the rule for the schema's closed string unions (see theAcpSchemaJavadoc): a record over the wire string with the constantsACCEPT,DECLINEandCANCEL,of(String),known()andisKnown(). An action this SDK does not know (a newer peer) is kept and written back instead of failing the response. Migration: compare withequals, not==, and replace aswitchover the enum withif/elseor a switch overaction().value(). -
Elicitation form properties of an unknown type and multi-select items of an unknown shape no longer fail the request. A property of an unknown type reads as
UnknownElicitationPropertySchema, keepingtypeand every other field, and is written back unchanged, as for the other discriminated unions. Multi-select items have no shared discriminator, so the SDK tells their shapes apart by their members: items with atypeor anenummember read asUntitledMultiSelectItemsand items with ananyOfmember asTitledMultiSelectItems, whatever thetype, and lose the members that record does not have. Only items with none of these members read asUnknownMultiSelectItems, which keeps every field and writes it back unchanged. The five property schemas now write their owntypediscriminator: their canonical constructors acceptnullfor it (it becomes the record's type) and reject a different type, which they used to accept and ignore. -
Breaking:
StringPropertySchemaandMultiSelectPropertySchemahave only their canonical constructors, which end with_meta. Migration: passnullas the last argument, or useStringPropertySchema.text(...)andsingleSelect(...). -
Behaviour change: the agent checks the requested elicitation mode, not only elicitation.
createElicitationfails withAcpCapabilityException(elicitation.formorelicitation.url) when the client did not advertise the request's mode; before, anyelicitationobject, even{}, let both modes through. The spec says agents must not request a mode the client did not advertise. A mode the SDK does not know still needs onlyelicitation. -
Behaviour change: a client with a typed
createElicitationHandleranswers a request for a mode it did not advertise at initialization with-32602(invalid params), as the spec says, without calling the handler. Before initialization, and for a mode the SDK does not know, the handler is called. Migration: advertise the modes the handler supports inclientCapabilities.elicitation. A rawrequestHandler("elicitation/create", ...)is not checked. -
Unknown union variants no longer fail the message (ACP forward compatibility). A newer agent's
sessionUpdatetype used to fail the wholesession/updatenotification, which the client then dropped with an error log; an unknown content block, tool call content type, config option type or permission outcome failed its message the same way. Each union now reads an unknown variant as itsUnknown*record (UnknownSessionUpdate,UnknownContentBlock,UnknownToolCallContent,UnknownSessionConfigOption,UnknownPermissionOutcome), which keeps the discriminator and every other field and writes them back unchanged. Session update consumers receive it like any other update; code that does not know the variant ignores it. The Rust, TypeScript and Python SDKs drop such a notification; the Kotlin SDK and the v2 schema keep the raw payload, which this SDK follows. The rule is documented onAcpSchema. Unions with a default variant keep it (McpServerreads an unknowntypeas stdio). -
Breaking: a select config option's
optionsis flat or grouped. The schema'sSessionConfigSelectOptionsis a list of options or a list of groups (SessionConfigSelectGroup:group,name,options,_meta); a grouped list failed to read.SessionConfigSelect.options()now returnsSessionConfigSelectOptions, eitherUngroupedSelectOptionsorGroupedSelectOptions, both written as the JSON array they read from;allOptions()lists every option across groups, and a list mixing options and groups is rejected. Migration:select.options()used as a list becomesselect.options().allOptions()(or match on the two records); the canonical constructor takesSessionConfigSelectOptions.ungrouped(list)orgrouped(groups); the(id, name, currentValue, List<SessionConfigSelectOption>)constructor is unchanged. -
Breaking: unknown enum values no longer fail the message; most schema enums are open value types. A newer peer's stop reason, tool call status, permission option kind, plan entry status or priority, or annotation role failed deserialization of the whole message (a prompt's response included).
StopReason,ToolCallStatus,PermissionOptionKind,PlanEntryStatus,PlanEntryPriorityandRoleare now records over the wire string: the constants keep their names (StopReason.END_TURN),of(String)returns the constant for a known value, an unknown value is kept and written back (isKnown()false),known()lists the defined values andtoString()is the wire value.ToolKindstays an enum and reads an unknown kind asOTHER, the schema's catch-all (as in the Rust SDK); it gainsvalue()andof(String). The rule is documented onAcpSchema. Migration: compare withequalsinstead of==(a value built withnewis not the constant); replaceswitchover these types withif/equalsor a switch onvalue();values()becomesknown(),name()becomesvalue()(the wire name, e.g.end_turn, notEND_TURN). -
Breaking: union records write their own discriminator, and reject a wrong one. Every union above is now declared
include = EXISTING_PROPERTY, so thesessionUpdate,typeoroutcomecomponent of a record such asAgentMessageChunkorTextContentis written as is. Before, Jackson replaced whatever the caller passed with the registered name. A canonical constructor now turnsnullinto the record's own name and throwsIllegalArgumentExceptionfor any other value (new AgentMessageChunk("agentMessage", ...)used to be silently corrected). Migration: pass the variant's wire name ("agent_message_chunk") ornull, or use the convenience constructors, which already do. Code that switches exhaustively over a union's known records needs a branch for itsUnknown*record. -
Breaking: the canonical constructors of
NewSessionResponse,LoadSessionResponseandResumeSessionResponsetakeconfigOptionsbefore_meta.new NewSessionResponse(id, modes, null)andnew LoadSessionResponse(modes, null)still compile (thenullis now the config options); a call that passed a_metamap in that position no longer compiles. Migration: pass(id, modes, configOptions, meta)or(modes, configOptions, meta). -
Breaking:
ToolCall,ToolCallUpdateandToolCallUpdateNotificationtakenameaftertitle. Migration: insert the tool's name, ornull, after the title argument of the canonical constructor. -
Breaking:
AuthMethodis an interface; the agent method isAuthMethodAgent. The schema makesAuthMethoda union (notype: agent;"terminal": terminal). A method of any other type reads asAuthMethodAgent, as in the Rust SDK. Migration:new AuthMethod(id, name, description)becomesnew AcpSchema.AuthMethodAgent(id, name, description);id(),name()anddescription()stay on the interface.AuthMethodAgentalso carries_meta. -
Breaking:
ClientCapabilitiestakessessionandauthafterterminalin its canonical constructor, in schema order. Migration:new ClientCapabilities(fs, terminal, elicitation, meta)becomesnew ClientCapabilities(fs, terminal, session, auth, elicitation, meta)(both may benull). -
Breaking:
AgentCapabilitiestakesauthafterpromptCapabilitiesin its canonical constructor. Migration:new AgentCapabilities(loadSession, session, mcp, prompt, providers, meta)becomesnew AgentCapabilities(loadSession, session, mcp, prompt, auth, providers, meta)(authmay benull); the shorter constructors are unchanged. -
Behaviour change: a request whose caller gives up is cancelled at the peer. Disposing a request
Mono, or its timeout firing (the client's default request timeout is 30 seconds, the agent's 60), now sends$/cancel_request, so a peer that supports it stops the work and answers-32800. Asession/promptthat times out on the client is therefore cancelled at an agent that supports$/cancel_request(the Java agent answers it at once and ends the turn), where before the agent kept running the turn. Migration: raiserequestTimeoutfor long prompts, or sendsession/cancelfor the graceful prompt cancel with stop reasoncancelled. -
Fix: a resubscribed request is a new request. Subscribing twice to one
sendRequestMono(aretry(), say) reused the request id, so the first response completed both. Each subscription now gets its own id. -
Behaviour change:
session/cancelno longer ends the prompt turn; the cancelled prompt's response does. The agent session used to free the session for a new prompt as soon as the cancel notification arrived, so a client could start a second prompt while the cancelled one's handler was still running. ACP v1 (prompt turn, Cancellation) says the agent may still sendsession/updates after the cancel and must then answer the originalsession/promptwith stop reasoncancelled, and "once a prompt turn completes, the Client may send anothersession/prompt". A prompt sent between the cancel and that answer is now rejected with-32600(invalid request; see below), like any prompt during an active turn. The turn ends when the answer is published (before it reaches the client, as for every prompt), when the handler fails (a timeout the handler applies included), when the request is cancelled, when the session closes, or when the cancel grace period passes (below). Migration: clients send the next prompt after the cancelled one has answered (the SDK client sends nothing on its own aftercancel); agent prompt handlers must answer a cancelled prompt. -
New default: a cancelled prompt is answered within 60 seconds. If the prompt handler has not answered 60 seconds after
session/cancel, the agent cancels the handler's subscription and answers the prompt with stop reasoncancelleditself (the answer ACP v1 requires of a cancelled prompt), which ends the turn. Before, a handler that never answered a cancelled prompt kept its session busy for good.cancelGracePeriod(Duration.ZERO)restores that. The public four-argumentAcpAgentSessionconstructor uses the same default. -
Breaking (unstable providers API): the provider identifier is
providerId, on the wire and in Java.ProviderInfo,SetProviderRequestandDisableProviderRequestwrote and read"id", but the unstable schema names the propertyproviderId, so no spec-conforming peer could exchange them with the SDK. The record component is renamed with it. Migration:id()becomesproviderId(); the constructors keep their parameter order. -
Breaking:
AcpProtocolExceptionno longer converts to or fromJSONRPCError. UseAcpSchema.JSONRPCError.from(exception)instead ofexception.toJsonRpcError(), anderror.toException()instead ofnew AcpProtocolException(error). Dependencies between thespecanderrorpackages now run one way. -
Every package is null-marked (JSpecify
@NullMarked), and the nullness is now part of the API. Types are non-null unless annotated@Nullable, and the annotations say where absence is legal:AcpSchemarecord components follow the ACP schema (optional and nullable fields are@Nullable, 217 of them; required ones are not), as do the JSON-RPC envelope (id,params,result,error,data),AcpJsonMapper.readValue(the JSONnullliteral),getClientCapabilities()andgetAgentCapabilities()(before initialization),AcpProtocolException.getData(), and the annotation-driven support types (resolved arguments, handler return values, interceptor results). Kotlin and other nullness-aware callers see these types; Java callers compile unchanged. -
Breaking:
SyncPromptContext.askChoicereturnsOptional<String>, empty when the client cancels the choice (it was documented to return null, and failed instead, below).PromptContext.askChoicecompletes empty on cancellation. Migration:askChoice(...).orElse(...), or testisPresent(). -
Breaking:
SyncPromptContexthas an abstractasync()method, returning thePromptContextit blocks on (the same session, turn and client). Migration: an implementation ofSyncPromptContext, such as a test double, implementsasync(). -
Breaking:
CommandResultcarries a nullable exit code and the terminating signal, and notimedOutflag. Its components are(String output, @Nullable Integer exitCode, @Nullable String signal): a command killed by a signal has no exit code.timedOutis removed, with the(output, int exitCode, boolean timedOut)constructor:executehas no timeout (neitherCommandnorterminal/wait_for_exithas one), so it never set the flag, and a result could not report a timeout truthfully. The(output, int exitCode)constructor remains. Migration:exitCode()returnsInteger; usesuccess(), or checkexitCode()for null before comparing it. To bound a command, use the terminal methods (createTerminal,killTerminal) directly. -
Breaking:
Command.env()is never null; it is empty when no variables are set (Command.ofnow builds it withMap.of(), and the canonical constructor takes a non-null map). Migration: testenv().isEmpty()instead ofenv() == null. -
AcpException.getMessage()is declared non-null: every constructor sets a message. -
StdioAcpClientTransport.awaitForExit()beforeconnectthrowsIllegalStateExceptioninstead ofNullPointerException. -
AcpInvocationContext.Builder.build()requiresacpMethodandrequest. -
Breaking:
AcpInvocationContextandAcpMethodParametermoved tocom.agentclientprotocol.sdk.agent.support.invocation. The resolver, handler and interceptor packages used them fromagent.support, which in turn used those packages, so the four packages formed a cycle. They now depend on theinvocationpackage andAcpAgentSupportdepends on them, one way. Migration: change the imports in customArgumentResolver,ReturnValueHandlerandAcpInterceptorimplementations tocom.agentclientprotocol.sdk.agent.support.invocation.*. -
Breaking: the error a request fails with when the peer answers with a JSON-RPC error is one type,
com.agentclientprotocol.sdk.spec.AcpError, on both sides. It replaces the two identical nested classesAcpClientSession.AcpErrorandAcpAgentSession.AcpError; its constructor, message,getError(),getCode()andgetData()are unchanged. Migration: catch or test forAcpError(importcom.agentclientprotocol.sdk.spec.AcpError) instead ofAcpClientSession.AcpErrororAcpAgentSession.AcpError. -
com.agentclientprotocol.sdk.util.OutboundSinksis new: the emission onto a transport's outbound sink that retries while another thread emits (#14), and the reply pump from inbound messages through the session handler to that sink, which the stdio and WebSocket transports shared as copies. -
@UnstableAcpApimarks all of the unstablesession/forkandproviders/*API. Those methods are not in the stable schema, and their records, annotations and provider handlers were marked, butMETHOD_SESSION_FORKandMETHOD_PROVIDERS_*,AcpAgent.ForkSessionHandlerandSyncForkSessionHandler, both builders'forkSessionHandler,AcpAsyncClientandAcpSyncClientforkSession,listProviders,setProvideranddisableProvider,NegotiatedCapabilitiessupportsForkSession,supportsProviders,requireForkSession,requireProvidersand the builder'sforkSessionandproviders, and the four request resolvers were not. They are now; nothing else changes. Tests derive the unstable methods from the stable schema copy and fail when a public member serving one lacks the marker, or a stable method's member carries it. -
Breaking:
SessionConfigSelect.Builder.build()checks the option's value. It now throwsIllegalStateExceptionwhen the options (across all groups) are empty, or whencurrentValueis not the value of one of them, instead of building an option no client can display correctly. The record's constructors stay lenient, since they also read other agents' options. Migration: pass at least one option and setcurrentValueto one of their values. -
Breaking (behaviour): one default request timeout, 60 seconds. The client builders (
AcpClient.sync/async) andAcpAgentSupport.Builderdefaulted to 30 seconds while the agent builders defaulted to 60; all now wait 60 seconds for an answer unlessrequestTimeoutis set. 60 seconds because the agent's requests (a permission prompt, an elicitation) wait on a person, and a client'ssession/newmay wait for the agent to start its MCP servers.AcpAgentSupport.Builder.requestTimeoutnow acceptsnull, meaning the SDK default, and an annotated agent no longer restates the default. The Spring Boot propertyspring.acp.client.request-timeoutand the Micronaut settingacp.client.request-timeoutdefault to 60 s as well; Quarkus'squarkus.acp.client.request-timeout, unset, already follows the SDK. Migration: none needed; to keep the old bound, setrequestTimeout(Duration.ofSeconds(30))(or the property to30s). -
Breaking (behaviour): a client handler is registered once. On
AcpClient.AsyncSpecandSyncSpec, registering a second handler for a method (a typed setter such asreadTextFileHandlercalled twice,requestHandler,notificationHandler,extRequestHandler/extNotificationHandlerfor a method that already has one, or a raw handler and the typed setter for the same method) now throwsIllegalStateExceptionnaming the setter, instead of silently replacing the first. A null handler still throwsIllegalArgumentException.sessionUpdateHandlerstays additive. Migration: register each method once; to compose behaviour, do it inside one handler. -
Breaking: the raw client handlers are for methods the SDK does not model.
AcpClient.AsyncSpec/SyncSpec.requestHandler(method, ..)now throwsIllegalArgumentExceptionfor a method that has a typed setter (fs/read_text_file,fs/write_text_file,session/request_permission, the fiveterminal/*methods,elicitation/create), andnotificationHandler(method, ..)forsession/updateandelicitation/complete, naming the setter to use: the raw handler bypassed the typed params and the elicitation mode check, and a rawsession/updatehandler was silently replaced by the session update consumers.SyncSpec.notificationHandlernow takes a blockingConsumer<Object>run on the sync handler threads, like the sync builder's other handlers, instead of the asyncNotificationHandlerreturning aMono. Migration: use the typed setter the exception names; on the sync builder, drop theMonofrom a raw notification handler (params -> { ... }). -
Breaking (behaviour): a client's advertised capabilities must have their handlers.
AcpClient.AsyncSpec/SyncSpec.build()now throwsIllegalStateExceptionwhen the client capabilities advertisefs.readTextFilewithoutreadTextFileHandler,fs.writeTextFilewithoutwriteTextFileHandler,terminalwithout all five terminal handlers (createTerminalHandler,terminalOutputHandler,releaseTerminalHandler,waitForTerminalExitHandler,killTerminalHandler), or an elicitation mode withoutcreateElicitationHandler; the message names every missing setter. Such a client used to build, and the agent's requests were answered "Method not found". A handler for a capability the client does not advertise logs one WARN, since an SDK agent will not call it. The Spring Boot (spring.acp.client.capabilities.*), Micronaut (acp.client.capabilities.*) and Quarkus (quarkus.acp.client.capabilities.*) client beans add the setting that advertised the capability to the message. The testMockAcpClientno longer advertisesterminal, which it never served. Migration: register the handlers (in anAcpClientCustomizerwhen the capabilities come from properties), or stop advertising the capability. -
Breaking (behaviour): a client initializes first, and calls only what the agent advertised. Every
AcpAsyncClient/AcpSyncClientACP call butinitializenow fails withIllegalStateException("Call initialize() first") until the agent has answeredinitialize; it used to be sent.loadSession,listSessions,closeSession,deleteSession,resumeSession,forkSession,logoutand theproviders/*calls fail withAcpCapabilityException(naming the capability, such assessionCapabilities.close) when the agent'sinitializeanswer did not advertise it, as the agent side already does for the client's capabilities. Neither sends anything. The check runs when the call is subscribed, soclient.initialize().then(client.newSession(..))works. Extension methods (sendExtRequest,sendExtNotification) are outside ACP's lifecycle and are not checked. Migration: callinitialize()first; checkgetAgentCapabilities()before an optional call, or catchAcpCapabilityException. A test agent with its owninitializeHandlermust advertise the capabilities its handlers serve (or drop the handler: a builder agent's defaultinitializeadvertises them).
-
Breaking:
AcpCapabilityException.toProtocolException()is removed. It answered-32600(invalid request) for a missing capability, while the one refusal ACP specifies, a client asked for an elicitation in a mode it did not declare, is-32602(invalid params; ACP's elicitation RFD, "Error Handling"), which the SDK's client sends. The SDK never called the method, so it only offered a second code for the same case. Migration: thrownew AcpProtocolException(AcpErrorCodes.INVALID_PARAMS, e.getMessage(), e.getCapability()). -
Breaking:
AcpAgent.loggerandAcpClient.loggerare removed. As fields of public interfaces they were publicorg.slf4j.Loggerconstants, API by accident. Migration: use a logger of your own (LoggerFactory.getLogger(MyAgent.class)); to see the client builders' session-update logging, configure the logger namedcom.agentclientprotocol.sdk.client.AcpClientas before. -
Breaking:
PromptResponse.text(String)is removed, and a@Promptmethod's String now reaches the client.text(String)threw its text away and returned the same response asendTurn(): a prompt response carries no content, and a static factory cannot send a session update. So an annotated@Promptmethod that returned aString(documented as "converted toPromptResponse.text()") ended the turn and sent nothing. That String is now sent to the client as anagent_message_chunksession update of the prompt's session, then the turn ends withend_turn; a null or empty String sends nothing. Migration: replacereturn PromptResponse.text(message);withcontext.sendMessage(message); return PromptResponse.endTurn();(SyncPromptContextor, in an async handler,PromptContext), or, in a@Promptmethod, return the String itself. -
Breaking: the session-model API (
session/set_model) is removed. Deprecated for removal in 0.14.0; the ACP schema 1.9.1 (stable and unstable) no longer defines the method, its request and response, or themodelsfield on session responses. Removed:AcpSchema.METHOD_SESSION_SET_MODEL,SetSessionModelRequest,SetSessionModelResponse,SessionModelState,ModelInfo; themodelscomponent ofNewSessionResponse,LoadSessionResponse,ResumeSessionResponseandForkSessionResponse(and the convenience constructors that took it: they now take the session ID and/ormodesonly);AcpAgent.SetSessionModelHandler,SyncSetSessionModelHandlerand the builders'setSessionModelHandler;AcpAsyncClient.setSessionModelandAcpSyncClient.setSessionModel; the@SetSessionModelannotation andSetSessionModelRequestResolver; the method's Streamable HTTP routing entries. An agent no longer answerssession/set_model(a peer that sends it gets "method not found"). Migration: expose model selection as a session config option whosecategoryis"model", and change it withsession/set_config_option(setSessionConfigOptionHandler,@SetSessionConfigOption,AcpAsyncClient.setSessionConfigOption). -
Breaking: the
acp-websocket-jettymodule and itsWebSocketAcpAgentTransportare removed. Deprecated for removal in 0.18.0; the module held only that class, a WebSocket agent transport that served a single client. Migration: depend onacp-streamable-http-jettyand serve agents withStreamableHttpAcpAgentTransport, which accepts WebSocket upgrades at/acp(and the Streamable HTTP profile on the same path) and creates one agent per connection through anAcpAgentFactory: replaceAcpAgent.async(new WebSocketAcpAgentTransport(port, mapper))...build()withnew StreamableHttpAcpAgentTransport(port, mapper, AcpAgentFactory.async(t -> AcpAgent.async(t)...build())).WebSocketAcpClientTransport(inacp-core) is unchanged and connects to it as before. -
Breaking: the
@SessionStateand@AcpExceptionHandlerannotations are removed. Both were declared inacp-annotationsbut nothing implemented them: a@SessionStateparameter failed every call with "No resolver for parameter", and an@AcpExceptionHandlermethod was never called. Migration: keep per-session state in the agent, in a thread-safe map keyed by the session ID (an@SessionId Stringparameter or the request'ssessionId()), and remove it in the@CloseSession/@DeleteSessionhandler; handle exceptions in the handler method itself, or in anAcpInterceptor'sonError, and throwAcpProtocolExceptionto answer with a specific JSON-RPC error.
-
A WebSocket client receives the going-away close (1001) when a Jetty-hosted endpoint shuts down. On Jetty, the SDK listener (
StreamableHttpAcpAgentTransport) and a servlet on Jetty, the close frame is sent asynchronously, and the graceful shutdown completed once the agents had closed; Jetty was then stopped before the frame went out, and the client saw the connection drop (1006) instead. The drain now waits for each WebSocket's close to complete, within the shutdown timeout: a client that never answers the close holds the shutdown no longer than that. -
A WebSocket that opens while the endpoint shuts down no longer leaves its agent running. A graceful shutdown (
closeGracefully()of the SDK listener or of a host's endpoint) that came between a WebSocket upgrade's shutdown check and the start of its agent closed the connection while it had no agent yet; the agent created a moment later was started and never closed, and the shutdown did not wait for it.RemoteAcpConnection.startnow creates no agent once the connection is closing (it fails withAcpConnectionException, not reported to the exception handler), and a graceful close that arrives while the agent starts closes it once it has started. -
acp-test:
InMemoryTransportPairno longer drops a message when two threads send at once under load. A send that found another thread still emitting on the same direction retried for 100 ms and then failed withFAIL_NON_SERIALIZED, and the message was lost; since delivery runs on the emitting thread, a slow receiver or a descheduled thread held the sink that long. A send is now queued, and the thread already emitting delivers it, in order, without making the sender wait. -
Micronaut:
acp.agent.transport.typeandacp.client.transport.typebind in any case.AcpTransportTypereads the type in any case, as Spring Boot and Quarkus do, but Micronaut's own enum conversion took onlywebsocketorWEBSOCKET: a mixed-caseWebSocketfailed the agent's startup, and on the client was silently dropped, so the transport was inferred as if no type were set. Both now bind throughAcpTransportType.parse, and an unknown value fails the startup with "Unknown ACP transport type '...': use stdio, websocket or http" on both sides. -
Micronaut: only a type, a command or a URI creates the client beans. Any
acp.client.transport.*property created them, soacp.client.transport.websocket.connect-timeoutalone (orstdio.args,stdio.env.*) made the client beans, which then failed with "An ACP client needs a transport". The beans are now created only whenacp.client.transport.type,.stdio.command,.websocket.urior.http.uriis set, the test Spring Boot andAcpClientSettings.hasTransport()use. Behaviour change: such a property alone now leaves the application without a client, instead of failing it. -
Micronaut: a stdio agent's close has its own bound,
acp.agent.shutdown-timeout(default10s). Closing the context waited for a stdio agent at mostacp.agent.transport.http.shutdown-timeoutplus 5 seconds, a listener setting that a stdio agent otherwise ignores. The HTTP listener keeps that bound; a stdio agent, or anAcpAgentTransportbean of the application's own, now waits at mostacp.agent.shutdown-timeout, then closes at once. The default keeps the old default wait, 10 seconds, which is also as long as the SDK's own graceful close takes at most. Behaviour change: an application that set the HTTP key to bound its stdio agent's close setsacp.agent.shutdown-timeoutinstead. -
Spring Boot:
spring.acp.agent.enabled=falseturns the agent off on every transport. The stdio transport and the HTTP endpoint already backed off, butAcpAgentAutoConfigurationdid not: it still built theAcpAgentFactoryfrom the@AcpAgentbean and still served the agent on anAcpAgentTransportbean the application defined itself. Withenabled=falseit now creates no agent beans and serves nothing, as Micronaut and Quarkus already did. -
Spring Boot: two
@AcpAgentbeans no longer fail the startup whenspring.acp.agent.enabled=false. The startup failed with the error naming both beans, whose advice was to setspring.acp.agent.enabled=false. Same cause and fix as the entry above. -
Spring Boot:
spring.acp.agent.transport.type=httpin a reactive (WebFlux) web application served no endpoint and gave no error. It now serves the endpoint withacp-http-webflux, and fails the startup naming that module when it is missing (see Added and Changed; the interim "WebFlux is not supported" failure is gone). -
A prompt answers
cancelledoncesession/cancelwas received, whatever its handler returns. ACP requires an agent to answer a cancelled prompt with stop reasoncancelled. A handler that noticed the cancel and returned another stop reason within the grace period had that stop reason sent: avoid,String,Mono<String>orCompletionStage<String>@Promptmethod sentend_turn, and so did a builder handler returningPromptResponse.endTurn(). The agent session now sends a handler'sPromptResponsewith stop reasoncancelledoncesession/cancelwas received for that prompt, keeping its_meta, and logs the change at DEBUG. It uses the prompt's existing cancelling state, the one the cancel grace period uses. Behaviour change; migration: a handler that returned another stop reason after cancel now sendscancelled. -
A prompt that fails after
session/cancelanswerscancelled, not an error. ACP requires agents to catch the errors aborted work raises and answercancelled. Only a cancellation (CancellationException, an interrupt, anAcpProtocolExceptionwith-32800) was answeredcancelled; any other exception, such as the one a model client throws when its call is aborted, went out as-32603. Oncesession/cancelwas received for the prompt, any failure is now answered{"stopReason":"cancelled"}and logged at DEBUG. Before a cancel, failures are answered as before. Behaviour change; covered by the migration line above. -
A builder agent answers
session/newwithout a new-session handler. ACP requires every agent to supportsession/new; a builder agent built withoutnewSessionHandleranswered it-32601, so no client could open a session. It now answers with a random UUID as the session ID and no modes or config options, as an annotated agent without a@NewSessionmethod already did. Both use the newAcpSchema.NewSessionResponse.withGeneratedId(). A registered handler replaces the default, andbuild()still requires only a prompt handler. -
The agent checks the client's
terminalcapability for every terminal method. ACP forbids an agent to call any terminal method when the client did not advertiseterminal. Onlyterminal/createwas checked;terminal/output,terminal/wait_for_exit,terminal/killandterminal/releasewere sent regardless, for example with a terminal ID from another connection. All five now fail locally withAcpCapabilityExceptionand send nothing, from the agent and from a prompt context. -
A prompt's updates no longer follow its answer. ACP requires an agent to send a prompt's
session/updatenotifications before it answers the prompt, also aftersession/cancel. When the SDK answered a prompt itself (the cancel grace period ormaxPromptDurationpassed), a handler that kept running could still send updates through its prompt context, and they went out after the answer; so could a handler that sent through its context after returning its own answer. Once a prompt has been answered, by its handler or by the SDK,PromptContext.sendUpdateandSyncPromptContext.sendUpdate(and the helpers built on them, such assendMessage) drop the update and log it at DEBUG, without its content; theMonocompletes empty. The check uses the prompt's answered-once state that decides who answers. Updates sent withsendSessionUpdateoutside a prompt context are not affected. -
A builder agent advertises
providersfor any provider handler. Without an initialize handler, a builder agent advertisedprovidersonly for aproviders/listhandler, while an annotated agent does for any of the three provider methods; with only a set or disable handler the client refused those calls. It now advertisesprovidersfor any of them. -
SessionCapabilitieswrites only objects, and reads only objects as advertised. Its components are typedObject:Boolean.TRUEwrote"list":true, which the schema forbids (a session capability is an object), and on reading any non-null value counted as advertised, so a peer's"close": falsemadesupportsCloseSession()true. The record now holdsBoolean.TRUEas{}andBoolean.FALSEas absent, and reading JSON counts only an object as advertised; atrue,falseor other value reads as not advertised. The component types are unchanged. -
The
acp-agent-supportREADME's custom return value handler example no longer breaksCompletableFuture. The example registered aCompletableFutureHandler; custom handlers are asked before the built-in ones, so it replaced the built-inCompletionStagesupport and aCompletableFuture<String>from@Promptfailed every prompt with-32603. The example now adds a type the SDK does not handle (a plainFuture), and a test keeps it in step with the README. The README's interceptor example no longer saysafterCompletionis always called: it is called for the interceptors whosepreInvokereturnedtrue. -
AcpAgentSupport.Builder.interceptor(null)fails at once withIllegalArgumentException, asargumentResolver(null)andreturnValueHandler(null)do. It was accepted, and the next build failed with a bareNullPointerException. -
An
Errorwhile reading a stdio agent's output is reported as itself. When handling a line of the agent's standard output threw anError(typically aNoSuchMethodErrorfrom a Jackson version clash),StdioAcpClientTransportended as if the agent had "closed its standard output but has not exited". The real cause is now logged, passed to the exception handler, and is the cause of theAcpConnectionExceptionthat ends the transport ("Reading the agent's output failed: ..."). -
A message sent after a transport closed fails instead of being dropped.
StdioAcpClientTransport,StdioAcpAgentTransportandWebSocketAcpClientTransportdropped a message sent once they were closed and completed itsMono, so a request then waited out its timeout;OutboundSinks.emitdocumented a failure it never raised (Reactor'semitNextdrops a value on a terminated sink).sendMessagenow fails withAcpConnectionException("The transport is closed"), asStreamableHttpAcpClientTransportalready did, andOutboundSinks.emitthrowsSinks.EmissionExceptionwithFAIL_TERMINATEDas documented. -
WebSocketAcpClientTransportshuts down its own HTTP client's threads on close. A transport built without anHttpClientcreates one with a cached thread pool, which was never shut down, so its threads lingered for about a minute after close. Closing the transport now shuts that executor down; anHttpClientyou pass in is left alone. -
A
WebSocketAcpClientTransportwhose connect failed can connect again. The failedconnecthad already subscribed the transport's single-subscriber inbound sink, so a retry on the same transport "succeeded" but no message from the agent ever reached the handler (Reactor logged "unicast() sinks only allow a single Subscriber"). The inbound side is now subscribed only once the connection is open. -
CommandResultcarries the terminal output'struncatedflag (new last record componenttruncated, accessortruncated()).executedropped the flag of the client'sterminal/outputanswer, so a caller could not tell output cut to the command's output limit from complete output. Breaking only for code calling the canonical constructornew CommandResult(output, exitCode, signal), which still exists as a convenience constructor (complete output); the new canonical form isnew CommandResult(output, exitCode, signal, truncated). -
SyncPromptContext.tryReadFileno longer swallows the cancelling interrupt. It returned empty for every failure, including the interrupt the SDK uses to cancel a sync handler, and the interrupt flag was lost, so a cancelled handler carried on as if the file were missing. It now throwsCancellationException(interrupt flag set) when the handler is cancelled while it waits, as the other calls do; any other failure still gives empty. -
askChoicefails clearly when the client answers with an option it was not offered. It parsed the chosen option ID as an index, so an unexpected answer failed withNumberFormatExceptionorArrayIndexOutOfBoundsException. It now fails with anAcpError(-32603) naming the ID, with data{"reason": "unoffered-option", "method": "session/request_permission", "optionId": <id>}, as any answer the SDK rejects does; a cancelled request still gives an empty result. -
A request from the agent reaches its client handler only after the session updates sent before it. Requests were handled as they arrived, outside the ordered notification drain, so a
session/request_permissioncould reach its handler while the session update consumers had not yet seen thetool_callupdate the agent sent just before it, and the client could not look the tool call up. A request now takes its place in the drain like a response: it is dispatched once the notifications before it have been handled (handlers still run concurrently with each other and with later updates). To keep a consumer that waits for an answer of its own from deadlocking, held requests are dispatched at once while the running consumer waits for a response. A$/cancel_requestfor a request still held answers it-32800without calling its handler. Model checked with Lincheck (InboundOrderLincheckTest). -
PromptContext.execute/SyncPromptContext.executerelease the terminal when cancelled. ACP says the agent MUST release every terminal it creates (terminals.mdx).executereleased it when the command ended and when a step failed, but not when the prompt was cancelled (session/cancelgrace period,maxPromptDuration,$/cancel_request) while it waited interminal/wait_for_exit: the client kept the terminal. It now sendsterminal/releaseexactly once on every path. -
Annotated agents work as framework-proxied beans and with inherited handlers.
AcpAgentSupportread@AcpAgentand the handler methods from the bean's own class only. A proxy that Spring (CGLIB), Quarkus (ArC) or Micronaut (AOP) generates for the bean is an unannotated subclass, socreate(proxy)failed with "Class must be annotated with @AcpAgent", and handlers declared on a superclass were never found (the client got-32601). Discovery now walks the class hierarchy:@AcpAgentmay be on a superclass, handlers are collected from every class up toObjectand invoked on the instance given (through the proxy, so its interceptors run), an annotated override is one handler, and bridge and synthetic methods are skipped.agentInfo.namedefaults to the annotated class's simple name, not the proxy's. -
An annotated agent advertises what its annotations declare. Without an
@Initializemethod,AcpAgentSupportansweredinitializewithInitializeResponse.ok():loadSession: false, no session, auth or provider capabilities, noauthMethodsand noagentInfo. An agent with@LoadSession,@ListSessions,@ResumeSession,@CloseSession,@DeleteSession,@Logoutor@Authenticatetold clients it supported none of them, and clients that honour capabilities never called those methods;@AcpAgent(name, version)was never sent. The response is now derived from the annotation model:- each handler annotation advertises the capability its method needs:
@LoadSession→loadSession;@ListSessions,@ResumeSession,@CloseSession,@DeleteSession→sessionCapabilities.list/resume/close/delete;@Logout→auth.logout; the unstable@ForkSession→sessionCapabilities.forkand@ListProviders/@SetProvider/@DisableProvider→providers. The others need no capability (baseline methods, per-session modes and config options, extensions), andHandlerAnnotationCoverageTestfails if a new handler annotation has no declared mapping; @AcpAgent(name, version, title)→agentInfo; a blank name sends the class's simple name, a blank version the jar manifest'sImplementation-Version, else"unknown";- new
@AcpAgent(authMethods = @AuthMethod(...))→authMethods, agent or terminal methods. Terminal methods are advertised only to a client that announcedclientCapabilities.auth.terminal. Declaring an agent method without an@Authenticatehandler fails the build, as do duplicate ids and anenventry not writtenNAME=value; - new
@Prompt(image, audio, embeddedContext)→promptCapabilitiesand@AcpAgent(mcpHttp, mcpSse)→mcpCapabilities, default false; protocolVersionis the client's when the SDK speaks it, otherwise the latest it speaks.
Interceptors see the derived
initializeexactly as they see a declared@Initializemethod (preInvoke,postInvoke,onError,afterCompletion).An
@Initializemethod is now optional. When present, its response is laid over the derived one: a capability is advertised when either side advertises it, so existing code returningInitializeResponse.ok()keeps every derived capability; returnedauthMethodsfollow the derived ones, replacing any with the same id; the returnedprotocolVersion, and the returnedagentInfoand_metawhen not null, win.Migration: an
@Initializethat only returnedInitializeResponse.ok()can be deleted. One that built capabilities by hand can drop what the annotations now derive. A capability a handler implies can no longer be withdrawn from@Initialize; remove the handler instead. - each handler annotation advertises the capability its method needs:
-
session/closecancels the session's ongoing work before it closes. ACP v1 says the agent "must cancel any ongoing work related to the session (treat it as ifsession/cancelwas called) and then free up any resources associated with the session" (schemaCloseSessionRequest). The agent session passedsession/closestraight to its handler: a running prompt kept running and thesession/cancelhandler was never told. Now, when the agent has asession/closehandler, the session first does what asession/canceldoes: it tells thesession/cancelhandler (cancelHandler,@Cancel) and marks a running prompt cancelled, so it answers stop reasoncancelled(by itself, or from the session after the cancel grace period). The close handler runs once that prompt has answered. An agent without a close handler still answerssession/closewith "Method not found" and cancels nothing. -
A response result that lacks a required field fails the request. Only inbound params were checked for the fields the ACP schema requires, so a peer's
{}read as aPromptResponsewhosestopReason()was null, or aNewSessionResponsewhosesessionId()was null, against the records'@NullMarkedcontract. A result is now checked the same way, on both sides and down into nested records: the caller'sMonofails with anAcpErrorwith code-32603(internal error), the messageThe response to <method> lacks the required field <path>(for examplemodes.currentModeId) and data{"reason": "missing-required-field", "method": <method>, "field": <path>}. JSON-RPC 2.0 defines no code for an invalid response. A null result for a response type whose fields are all optional still reads as{}.A caller catches one type,
AcpError, for every request that failed with an error code: a peer's error response, or a response the SDK rejected itself. Besides a missing required field, a response without a result (data reasonmissing-result; it failed with anAcpProtocolException) and a result that cannot be read as the method's result type (reasonunreadable-result; it failed with the mapper'sIllegalArgumentException) now fail with anAcpErrorwith code-32603. A rejected response's data is a map withreasonandmethod; a peer's error carries the peer's data.askChoiceanswered with an option it did not offer fails the same way (reasonunoffered-option). Timeouts and capability checks keep their own types.AcpProtocolExceptionremains what a handler throws to answer with an error. Migration: catchAcpErrorinstead ofAcpProtocolException(orIllegalArgumentException) around a client or agent call. -
A failed close is logged as the failure it is.
AcpSyncClient.closeGracefully()logged "Client didn't close within timeout of 10000 ms" for any failure, also one that happened at once; it now logs that only when the timeout passes, and otherwiseClient close failed: <cause>with the cause (it still returns false).AcpTransport.close()'s default subscribed tocloseGracefully()without an error consumer, so a failure reached Reactor's "Operator called default onErrorDropped" at ERROR; it is now logged at WARN byAcpTransport's logger. The transports' and the client session's internal pipelines that subscribed without an error consumer now log their error (at WARN, or at DEBUG where it was already logged or reported), and a stdio client's standard-error handler that throws is logged instead of being dropped. -
Every transport can be closed more than once, with
close()andcloseGracefully()in either order.WebSocketAcpClientTransport.closeGracefully()sent a second WebSocket close frame on a second call, which failed withIOException: Output closed, so a secondAcpSyncClient.closeGracefully()returned false and logged stack traces (try-with-resources after an explicit close, or a container that destroys a client bean more than once, did this). Only the first call now closes; later calls complete when it has, and a close frame is not sent once the connection's output is closed.TransportCloseIdempotenceTestcloses every shipped transport (WebSocket client, Streamable HTTP client and its agent-side connection over HTTP and WebSocket, stdio client and agent, in-memory client and agent) twice in each order. -
The stdio client's graceful close lets the agent exit by itself, and logs the stop once.
StdioAcpClientTransport.closeGracefully()sent SIGTERM straight away without closing the agent's standard input, so an agent's end-of-input handling (an SDK stdio agent answers what it received, flushes, and exits 0) never ran and every close ended in exit 143. It now closes the agent's standard input first and waits up toStdioAcpClientTransport.END_OF_INPUT_WAIT_MILLIS(2 seconds) for the agent to exit; only an agent still running then is sent SIGTERM, and killed five seconds later as before. Closing again, ascloseGracefully()followed by try-with-resourcesclose()does, no longer stops the process a second time or logs "ACP agent process stopped" twice. -
prompt()returns only after the session-update consumers have handled the turn's updates. On the client, notifications are delivered one at a time on their own drain, while a response completed its caller as soon as it arrived. A prompt's response follows its turn's updates, so a syncprompt()(or the asyncMono) could return while the consumer was still handling the last updates, and a caller that read what it had collected missed some, silently. Now every response from the agent completes its caller only once every notification that arrived before it on the session has been handled: the response takes its place in the notification queue (InboundOrder, model checked with Lincheck inInboundOrderLincheckTest). Notification order,closeGracefully()waiting for the drain, andclose()not waiting are unchanged; a response still held when the session closes is completed then. A slow consumer delays the response, which still counts against the request timeout. So that a consumer which sends a request of its own and waits for it cannot deadlock, a response is not held behind a handler that was already running when its request was sent. A consumer must not wait for the prompt in flight to complete. The agent side is unchanged: its notification handlers are not ordered, andsession/canceltakes effect on the inbound thread before any later message is handled. -
Closing the Streamable HTTP servlet is bounded, and an
initializein flight no longer outlives it.StreamableHttpAcpServlet.destroy()waited up to 30 seconds (the initialize timeout) for its connections, andcloseGracefully()had no bound at all, so an agent whose close did not finish held the container's shutdown. Both now finish within the newStreamableHttpAcpAgentTransportOptions.shutdownTimeout(default 5 seconds; see Changed): a connection whose agent has not closed by then is closed at once (RemoteAcpConnection.close()now also closes a connection whose graceful close has not finished). The listener (StreamableHttpAcpAgentTransport.closeGracefully()) bounds its WebSocket connections the same way. Aninitializestill being answered when the servlet closes is answered503at once, instead of holding its request until the agent answered or 30 seconds passed, and an answer that arrives after the close no longer registers a connection on the closed servlet (it used to stay open, with its agent, until the JVM exited). Closing never waits for a client: SSE responses are completed, not drained. The 30-second stall reported from Spring Boot is notdestroy(): Boot shuts its server down gracefully by default, and every SSE stream a connected client holds is an in-flight request, so Boot waits itstimeout-per-shutdown-phase(30 seconds) before servlets are destroyed. The servlet Javadoc now documents the remedy: callcloseGracefully()before the server stops (aSmartLifecyclein the default phase). -
The stdio agent transport's hold on
System.inis documented. Closing aStdioAcpAgentTransportbuilt onSystem.incannot end a read already in progress, because a read ofSystem.incannot be interrupted: itsacp-agent-inboundthread stays blocked, holdingSystem.in's lock, until the next line or the end of standard input, then discards what it read and ends (now covered by a test). Under Maven Surefire, standard input is the fork's command stream, so such a reader, left by a test that builds the transport onSystem.in, took the fork's commands and stalled its exit until Surefire killed it 30 seconds afterSystem.exit(0). The class, itsSystem.inconstructors andcloseGracefully()now say so, and that embedders and tests should pass explicit streams, useacp-test's in-memory transport, or install an empty standard input before the tests run. The SDK's own tests never start a reader onSystem.in; the one that built the transport on it now passes explicit streams. TheAcpAgentSupportJavadoc example no longer calls aStdioAcpAgentTransport.create()that does not exist. -
An
@Promptmethod taking the asyncPromptContextfailed every call.PromptContextResolveraccepted the parameter, butAcpAgentSupportsupplied only theSyncPromptContext, so the prompt was answered with-32603"PromptContext is only available for @Prompt handlers". The parameter now receives the context the sync one wraps (SyncPromptContext.async()), so a@Promptmethod can take either and return aMono<PromptResponse>composed from the async calls. -
Annotated handlers other than
@Promptcould not takeNegotiatedCapabilities. Only the prompt handler was given them, so a@NewSession(or any other) method taking them failed with-32603"NegotiatedCapabilities not available in current context". Every handler,@Initializeand extension handlers included, now receives the capabilities negotiated on its own connection (the agent records them from the client'sinitializerequest before the handler runs). -
A handler that threw an
Errorleft the peer waiting. Reactor rethrows what it treats as JVM-fatal (LinkageError,VirtualMachineError) instead of signalling it, so a handler that threw, say, theNoSuchMethodErrorof code compiled against 0.18.0 calling a removed constructor unwound the handler thread, its request was never answered, and the peer waited out its request timeout. Now anyThrowableescaping a request handler, sync or async, on the agent and the client, annotation-based handlers included, is answered with-32603(Internal error), messageInternal error in the <method> handler (<error type>)with no stack trace, error message or payload; it is logged at ERROR (InboundMessages) with the throwable, and the connection keeps serving. A notification handler'sErroris logged at ERROR and skipped. AVirtualMachineError(OutOfMemoryError,StackOverflowError) is not hidden: it is answered the same way as far as the JVM still can, and also handed to the uncaught-exception handler of the thread that ran the handler, where it would have gone had it escaped (JVM options such as-XX:+ExitOnOutOfMemoryErroract when it is thrown and are unaffected). Not reachable: an async handler'sMonothat throws a JVM-fatal error inside its own operators after the handler returned, which Reactor rethrows on the emitting thread.HandlerFailures(util) holds the guard. -
AcpAgentSupport.Buildercould not be built twice.build()appended the default argument resolvers and return value handlers to the builder's own lists, so a second build duplicated them, a custom resolver or handler added after the first build came after the defaults and never ran, and agents already built shared, and saw changes to, the builder's lists.build()now composes the custom entries and then the defaults into a snapshot without changing the builder; each agent keeps the configuration it was built with. -
Params of an application's own record type were rejected as missing their fields. The required-field check on inbound params (
-32602) walked every record, so a non-public record that the SDK could not read reported each of its fields missing, and an application record was held to rules only the schema defines. It now checks the schema's records (AcpSchema) only. -
A stdio agent dropped the replies to requests that arrived just before its input ended. When the client closed the agent's standard input,
StdioAcpAgentTransportcompletedawaitTermination()at once and stopped writing, so an agent written as documented (agent.start().then(agent.awaitTermination()).block()) exited with replies still to come: a client that writes its requests and closes the pipe (a script, a test) got none, or some, of its answers. The end of standard input is now a half-close, not a cancellation: every request already received is handled and answered, the notifications its handler sends are written in order, then standard output is closed andawaitTermination()completes. A request the agent sends to the client, which can no longer answer, fails at once: one waiting when the input ends with a-32603error response, one sent after it with anAcpConnectionException. The drain is bounded by a drain timeout,StdioAcpAgentTransport.DEFAULT_DRAIN_TIMEOUT(60 seconds) or the one given to the new constructorStdioAcpAgentTransport(AcpJsonMapper, InputStream, OutputStream, Duration); a request still unanswered then is answered with-32800(request cancelled) and the transport terminates without it.closeGracefully()now also completesawaitTermination(). -
JSON-RPC request validation (JSON-RPC 2.0 sections 4 and 5.1), on both sides and every transport. A request whose
methodis not a string was answered-32601(method not found); one whoseidis not a string, number or null (an object, say), or whosejsonrpcis not exactly"2.0", was executed. All three are now answered-32600Invalid Request, with the request's id when it can be read andnullotherwise (section 5);AcpSchema.deserializeJsonRpcMessagerefuses them, andunreadableMessageResponseanswers them. Over Streamable HTTP a JSON object posted on a connection that is no valid request is answered on the connection stream with202, as the TypeScript server does; a body that is not a JSON object is still refused with400. Params the method cannot read are now-32602Invalid params, messageInvalid params, with the reason asdata, instead of-32603, and the handler is not called: a value of the wrong JSON type, and a required field (a schema record component without@Nullable, the schema'srequiredlist) that is missing, such assession/newwithoutcwd. The newAcpTransport.unmarshalParamsdoes this for both the agent's and the client's handlers. A notification whose handler fails (such params, say) is now logged at WARN and skipped; on the agent the failure used to end the session's inbound stream, so the connection stopped answering. The Streamable HTTP client now answers an SSE event that is no JSON-RPC message, in the scope of its stream, as the stdio and WebSocket transports do; it used to skip it, and the agent waited for an answer. -
Behaviour change: the default JSON mappers no longer coerce scalars.
JacksonAcpJsonMapperandJackson3AcpJsonMapperread"cwd": 42as the string"42", and a string as a number or boolean; they now refuse a scalar of the wrong JSON type (ALLOW_COERCION_OF_SCALARSoff, and no number or boolean to string), so such params are-32602. -
A stray response without an id was logged at ERROR as a bug in this SDK. A response with no id and no error answers no request this side sent; it is now logged at WARN.
-
A JSON-RPC error's wire
messagecarried the code. AnAcpProtocolExceptionthrown by a handler was answered with the exception's log form as the message, for example"message": "[-32602] unknown directive: #nope"; JSON-RPC 2.0 (section 5.1) keeps the number incodeand a short description inmessage. The wire message is now the plain message ("unknown directive: #nope").AcpProtocolException.getMessage()still prefixes the code, for logs; the newgetErrorMessage()returns the plain message, andJSONRPCError.fromuses it, so a received error passed on is unchanged. -
Streamable HTTP routed
session/delete,session/close,session/list,session/set_config_option,session/forkandlogoutby guesswork, and the server refused a session it did not know with 404. The client had rules for only seven methods and inferred the rest from their params with a WARN per call; the server likewise. Both now route every client-to-agent method of ACP v1, stable and unstable, from a table:authenticate,logout,session/new,session/list,providers/*,nes/start,mcp/messageand$/cancel_requeston the connection; every method whose params require asessionId(session/prompt,cancel,set_mode,set_config_option,close,delete,fork,load,resume,nes/suggest,accept,reject,close,document/*) session-scoped withAcp-Session-Id, as the transport RFD's Identity Model asks and the Rust and TypeScript SDKs do. Only methods outside v1, such as extension methods, are still routed by their params (logged at DEBUG). The server no longer answers a session-scoped POST naming a session the connection does not know with 404: the RFD's POST decision tree has none, so the request goes to the agent, which decides; asession/deleteof a session that never existed succeeds, assession-delete.mdxasks. Its reply comes on that session's stream when it has one, else on the connection stream.session/deleteandsession/forkare accepted withoutAcp-Session-Id, assession/loadwas, and answered on the connection stream: the RFD names no session-scoped method and the Python client sends neither with the header. A session-scoped GET for a session the connection does not know still opens a bounded provisional stream: the RFD's GET tree says 404, but its own resume flow opens the session stream beforesession/load, and the TypeScript client does so forsession/loadandsession/delete(it fails against a server that answers 404). The client now copes with a server that does answer 404 (the Python server): it posts the request on the connection withoutAcp-Session-Id, and the reply comes on the connection stream. -
A stdio client did not notice its agent process exiting.
StdioAcpClientTransportdid not implementawaitTermination(), so when the agent exited or crashed, pending requests waited out the request timeout. It now completes when the transport is closed, and errors with anAcpConnectionExceptionsuch asACP agent process exited with code 137 (signal 9)once the agent's output has ended and the process has exited on its own; the session fails pending requests with it at once. -
A peer's error answer to a message it could not read was logged as an SDK bug. An error response with
"id": null(what JSON-RPC 2.0 prescribes, and what this SDK and its peers send, for a message whose id could not be read) was logged at ERROR as "a bug in the request sender code". It is now logged at WARN as the peer's error report, with its code and message. -
Inbound payloads no longer reach logs above DEBUG. An unhandled notification was logged at WARN with its params: Claude's and Codex's agents send
_auth/status_update, whose params carry the account's email address in login mode.AcpClientSessionnow logs the method only, asAcpAgentSessionalready did. The same rule now holds wherever an inbound message reached a log at INFO or above: an error response'sdata(OutboundMessages), a line from the agent that is not a JSON-RPC message and a message the stdio client could not queue (StdioAcpClientTransport), and a message of unknown type (both sessions). Each is logged at DEBUG instead. The exceptionsAcpSchema.deserializeJsonRpcMessagethrows for JSON that is no JSON-RPC message, and for a request JSON-RPC 2.0 does not allow, which every transport logs, no longer quote the text. A dropped outbound response is logged without its content too (OutboundSinks). -
closeGracefully()could wait out the whole request timeout when a notification arrived as it closed. Found by the real-agent smoke tier: Claude's ACP agent sends asession_info_updateabout 10 ms after a prompt answers, and closing the client right after the turn hung for the request timeout in 4 of 9 runs. A notification handler runs on the inbound thread inside the emission on the session's notification sink; the close's completion of that sink collided with it, was refused (FAIL_NON_SERIALIZED) and dropped, and the close then waited for a drain that never ended. Completion is now never lost: the close records that completion is wanted, and an emission it collided with completes the sink once it returns (model checked with Lincheck,NotificationQueueLincheckTest). A bounded busy-loop retry would not do: a handler that works synchronously holds the sink for as long as it runs. Notifications are still delivered in order through one drain,closeGracefully()still waits for it at most the request timeout, andclose()still interrupts it. -
Both stdio transports stopped reading on the first line that was not a JSON-RPC message.
StdioAcpAgentTransportandStdioAcpClientTransportended their inbound stream on one malformed line, so the peer's later messages were never read; the client did so without telling anyone. Such a line is now logged, reported to the transport's exception handler, answered, and skipped, and reading goes on; a blank line is skipped silently. The answer is the one JSON-RPC 2.0 prescribes when the receiver cannot read a message, so cannot know whether it was a request: an error response with"id": nulland code-32700(Parse error) for text that is not JSON, or-32600(Invalid Request) for JSON that is no JSON-RPC message. Both stdio sides answer, since each serves the requests of the other; the TypeScript SDK's stdio and WebSocket streams do the same.StdioAcpClientTransportnow implementssetExceptionHandler(it inherited the no-op default), and also reports read and write failures to it. -
The WebSocket transports now answer a malformed message the same way.
WebSocketAcpClientTransportalready skipped and reported one, and now also answers it.StreamableHttpAcpAgentTransport's WebSocket upgrade closed the whole connection (code 1002) on one; it now answers, reports and skips it, and the connection stays open. A Streamable HTTP POST with a malformed body is still refused with 400, the HTTP answer, and a malformed SSE event is skipped unanswered (an SSE stream has no reply channel) but is now reported to the client transport's exception handler.AcpSchema.unreadableMessageResponse(jsonMapper, text)builds the answer, andJSONRPCResponsenow writes a null id as"id": null(it omitted it), as JSON-RPC 2.0 requires of a response. -
The transport errors of a Streamable HTTP or WebSocket connection were only logged. The agent runtime installs no exception handler on the transport
RemoteAcpConnectiongives it, and the listener offered no way to install one, so a host never saw a connection's transport errors.StreamableHttpAcpAgentTransport.setExceptionHandlerandStreamableHttpAcpServlet.setExceptionHandlernow receive them for every connection, HTTP/SSE and WebSocket alike, asStdioAcpAgentTransport.setExceptionHandlerdoes for stdio; the default still logs them, and an agent factory may still install its own on the transport it is given.RemoteAcpConnectiontakes the handler as a new constructor argument. -
A request that timed out or was cancelled stayed registered until the session closed. Both session sides kept the entry for every request still awaiting a response, and removed it only when the response arrived or the session ended. A request that hit the request timeout, or whose subscriber cancelled, kept its entry, so a long-lived session grew by one entry per such request, and a late response was silently swallowed rather than logged as unexpected. The entry is now removed when the request times out or is cancelled (when its sink is disposed, and only if the entry is still that request's). Found by the PIT mutation-testing pilot and, independently, by model checking with Lincheck (
PendingResponsesLincheckTest). -
Streamable HTTP: a JSON-RPC message with
"id": nullbroke the agent transport. A client request posted with"id": null(legal JSON-RPC) was answered with"id": null, and routing that answer looked the id up in a map that rejects null keys; theNullPointerExceptionclosed the whole connection. A client error response with"id": null(JSON-RPC's answer to a request it could not parse) failed the POST with 500. The answer now goes to the connection stream, and the response is accepted with 202. -
StreamableHttpAcpClientTransport: a response with"id": nullfailed the stream reader. An agent's error response with"id": null(JSON-RPC's answer to a request it could not parse) was looked up in a map that rejects null keys, and theNullPointerExceptionreplaced delivery; a client response with"id": nullfailed the same way on its way out. Both are now delivered. -
AgentParameters.Builder.argafterargs(String...)threwUnsupportedOperationException: the varargs form kept the fixed-sizeArrays.asListview. It now copies the arguments. -
StreamableHttpAcpAgentTransport's WebSocket upgrade closed the connection on any inbound text message over 64 KB (Jetty's default; close code 1009), so a prompt carrying a file of that size ended the session. The single-clientWebSocketAcpAgentTransportaccepted 4 MB. The upgrade now accepts messages up toStreamableHttpAcpAgentTransportOptions.maxPostBodyBytes(16 MB by default), the same inbound limit as a Streamable HTTP POST body. -
Streamable HTTP: an event written as the client dropped its SSE stream could be lost. When a client closed a session stream (an HTTP/2 reset) and the agent emitted an event before the server had detached that stream, Jetty accepted the write and its flush, and reported the failure only on the next write; the event was counted as sent and the reconnecting client never received it (a prompt's
session/updatewent missing while its result arrived). A subscriber now keeps each event it writes until the write has completed without error, checking a write that completed at once with an empty write, and returns unconfirmed events to the front of the stream's mailbox when it goes away, so the next GET receives them in order. An event whose write was still pending when its stream was replaced may now arrive twice rather than not at all. Bytes the server did put on the wire before the client's reset arrived can still be lost: closing that needs SSE event ids andLast-Event-ID, which the transport RFD defers to v2. -
A response with
"result": nullfailed the request ("carried no result") even where the result is an empty object, a regression in this release: the Python SDK answersfs/write_text_filewith"result": nullwhen its handler returnsNone, so a Java agent could not write files through a Python client. As in the Rust SDK (default_on_null, schema 1.9.1), a response type whose every field is optional now reads a null or missing result as{}; these types implement the new markerAcpSchema.DefaultOnNull(AuthenticateResponse,LogoutResponse,LoadSessionResponse,ResumeSessionResponse,CloseSessionResponse,DeleteSessionResponse,SetSessionModeResponse,WriteTextFileResponse,ReleaseTerminalResponse,KillTerminalCommandResponse,WaitForTerminalExitResponse,SetProviderResponse,DisableProviderResponse). A response type with a required field still fails clearly. A null result of an extension (_-prefixed) method completes the request'sMonoempty, and a message with anidbut nomethod,resultorerroris read as a response without a result instead of being rejected. -
An interceptor's
afterCompletionran twice when a later interceptor vetoed the call or threw frompreInvoke: once insideInterceptorChain.applyPreInvokeand again from the invocation's cleanup.applyPreInvokeno longer runs cleanup, andtriggerAfterCompletionruns at most once per chain, soafterCompletionruns exactly once per invocation on every path.
Found by the NullAway adoption; each has a test.
- A request or notification that omits
params(legal JSON-RPC) reached its handler asnull; it now reads as an empty object, as if the peer had sent{}. - A request whose handler produced no result got no JSON-RPC response at all, so the peer waited for
its timeout: a core request handler that completed empty, and in
acp-agent-supportavoidor null-returning handler method, or one vetoed by an interceptor. Such requests are now answered with anINTERNAL_ERRORsaying the handler produced no response. - A handler exception without a message produced a JSON-RPC error without the required
message; the exception's type name is sent instead. - A success response without a
resultfailed the request with an opaqueNullPointerException; the error now says the response carried no result. PromptContext.askChoicefailed the prompt with aNullPointerExceptionwhen the client cancelled.PromptContext.executefailed the prompt with aNullPointerExceptionwhen the command was killed by a signal (no exit code).AcpSchema.deserializeJsonRpcMessagethrewNullPointerExceptionfor the JSONnullliteral instead of the documentedIllegalArgumentException.
Found by enabling Error Prone's bug checks; each has a test.
StdioAcpClientTransportread the agent's stdout and stderr in the platform default charset while writing to it in UTF-8. On Java 17 that charset follows the platform (Cp1252 on Windows, US-ASCII under a POSIX locale), so non-ASCII text from the agent arrived garbled. Both are now read as UTF-8.- The Streamable HTTP client rejected an initialize response whose
Content-Typewas upper case when the JVM's default locale was Turkish ("APPLICATION/JSON".toLowerCase()is"applıcatıon/json"there). The client's and the servlet's media-type checks, and theos.namecheck inAgentParameters, now lower-case withLocale.ROOT. - In
acp-agent-support, a handler method whose result was not the ACP method's response type failed with an opaqueClassCastExceptionmessage; theINTERNAL_ERRORnow names the method and both types.
Found by model checking with Lincheck; each has a Lincheck test as its regression.
AcpAgentSession.hasActivePrompt()andgetActivePromptSessionIds()could report no active prompt while one was active, when a prompt of another session started or ended at the same moment: they asked theConcurrentHashMapfor its size, whose counters concurrent updates change after the entries themselves. They now look at the entries.- When the transport terminated while the session was closing, each request still waiting for a response was failed twice (Reactor dropped the second failure), and a request registered during that dismissal could be cleared from the table without being failed, leaving its caller to wait for the timeout. Each request is now removed from the table before it is failed, so it is failed exactly once.
- Streamable HTTP: a session stream first used after the connection closed stayed open. A
session GET that raced the connection's close (a
DELETE, or the agent ending the connection) held its response open until the client gave up, and agent messages for a session after close queued in a new stream nobody would read. Such a stream is now closed, so the GET completes at once, as on the connection stream. The connection's session table now makes each check and the change it decides one step.
Found by measuring coverage with JaCoCo; each has a test.
- Annotated agents registered by class got a new instance for every request.
AcpAgentSupport.create(MyAgent.class)andbuilder().agent(MyAgent.class, factory)created the agent anew on each handler call, so state kept in its fields (sessions stored by@NewSessionand read by@Prompt, as in the module README's complete example) was lost between requests. The agent is now created once, when it is registered, and that instance serves every request; a class that cannot be instantiated fails at registration with anIllegalArgumentExceptionrather than on the first request. The module README's factory example, which called acreate(Class, Supplier)that does not exist, is corrected.
-
The Quarkus tests no longer bind the fixed port 8081. Quarkus serves
@QuarkusTestandQuarkusUnitTestapplications onquarkus.http.test-port, 8081 by default, so two builds on one machine (or anything else on 8081) failed with "Port already bound: 8081". The deployment module's tests now run withquarkus.http.test-port=0, a random port, and the integration tests on a free port a test resource picks (the client bean's URI is read at startup, before a random port is known). The tests read the bound port through@TestHTTPResource. -
Spring Boot's configuration metadata is written on JDK 21 and later too. The
errorproneprofile (JDK 21+) sets the compiler's processor path to Error Prone and NullAway, which turns off discovering processors on the classpath, soacp-spring-boot-autoconfigurebuilt on JDK 21 had noMETA-INF/spring-configuration-metadata.json(no IDE completion or descriptions for the 32spring.acp.*properties) and nospring-autoconfigure-metadata.properties. The module now namesspring-boot-configuration-processorandspring-boot-autoconfigure-processoron its processor path, so every JDK writes both. Released jars, built on JDK 17, were not affected. -
The Javadoc build knows the
@apiNote,@implSpecand@implNotetags. They were not registered with the javadoc plugin, so their text was silently left out of the generated pages (and doclint reports them as unknown tags). The parent POM now registers them, with the JDK's headings. -
The JaCoCo coverage gate skips with the tests (
-DskipTests). It read whatever execution data an earlier run had left intarget/, so./mvnw -DskipTests installafter a-Dtestrun (a partialjacoco.exec) failed the gate; this broke the cross-SDK suite's SDK install. -
Model checking with Lincheck (
org.jetbrains.lincheck:lincheck3.7, test scope) for the SDK's concurrent state: the single-turn prompt rule and its release before the response is published (#14), the requests waiting for a response, emission on a transport's outbound sink, the Streamable HTTP SSE mailbox (no event lost or reordered across reconnects, client resets and pending writes) and the connection's session table. Each test checks every interleaving of a bounded, seeded set of scenarios against a sequential specification and invariants, and was shown to fail, with a minimal interleaving, against the bug it guards. They run in the normal build (about a minute in total); thelincheckCI job runs them with ten times the scenarios. The single-turn rule and the requests table moved into their own classes (ActivePrompts,PendingResponses) so that they can be checked without a transport. -
Architecture rules (ArchUnit) guard the package structure: acp-core's layers (util and json under the protocol, protocol under capabilities, capabilities under client and agent, which never depend on each other), no package cycles, no Jackson databind in acp-core, and no Jetty types on the path of
StreamableHttpAcpServlet, which must stay mountable in any Servlet 6 container. -
The architecture rules cover every module. Each module's rules import only that module's main classes and check that the import is non-empty (
archRule.failOnEmptyShould=true), and every module is free of package cycles at any depth. acp-annotations depends on nothing in the SDK; the JSON modules use only acp-core's JSON SPI and the schema; acp-agent-support layersAcpAgentSupportover independent resolver, handler and interceptor packages over the invocation model, and uses only acp-core's public agent API (no session implementations, client code, concrete transports or JSON implementation); acp-test uses only public SDK types and nothing from the annotation layer; and the transports, in acp-core and acp-streamable-http-jetty, see only the protocol (thespectransport interfaces and schema) plus, on the agent side, the per-connection seam (AcpAgentFactory,AcpAsyncAgent), never the session implementations or other client and agent runtime classes. The session (spec) and transport packages also have no dependency cycles between classes. -
Null safety is enforced, not just declared: every package is
@NullMarked(JSpecify 1.0.0, a compile dependency of each module; optional inacp-annotations, which keeps no transitive dependencies), and NullAway 0.13.8 on Error Prone 2.50.0 checks main sources at ERROR. Error Prone needs JDK 21, so the check runs in anullawayprofile activated on JDK 21+ and in a new JDK 21 CI job; the JDK 17 build and release are unchanged. Compilation uses--release 17, so a JDK 21 build still compiles against the Java 17 API..mvn/jvm.configopens the javac internals Error Prone needs (harmless on JDK 17). -
Error Prone's own bug checks run beside NullAway, at ERROR, in the JDK 21 build. The profile is renamed
errorprone(fromnullaway), and so is its CI job. Every check Error Prone enables by default runs, andfailOnWarningmakes its warnings fail the build too (as it does javac's own default warnings, such as use of an API deprecated for removal). One check is disabled as style,StatementSwitchToExpressionSwitch. Two more are enabled:SystemOut, since anything printed to standard out corrupts a stdio agent's JSON-RPC stream, andCheckReturnValueoverreactor.core.publisher, so a Mono or Flux operator whose result is dropped (and never runs) fails the build;config/errorprone/reactor-ignorable-results.txtlists the results that may be dropped, with reasons. Still main sources only, still JDK 21+ only. -
A scheduled dependency vulnerability scan (
dependency-scan.yml) checks the resolved runtime dependency tree of every module against OSV weekly, on demand, and on each push tomainthat changes apom.xml, so a CVE published after a release no longer waits for someone to look. Maven resolves the tree (a CycloneDX BOM, test scope excluded) and osv-scanner 2.6.0 checks it; no API key or secret is involved. The job fails on HIGH or CRITICAL (CVSS 7.0 or more) or an unscored finding, lists lower findings without failing, and uploads SARIF to code scanning. An unfixable finding can be accepted inosv-scanner.tomlwith a reason and anignoreUntildate, after which it fails again. Run it locally with.github/scripts/osv-scan.sh. -
A size, complexity and duplication gate fails
verify: maven-pmd-plugin 3.28.0 (PMD 7.28.0) runspmd:checkandpmd:cpd-checkon main sources with the ruleset inconfig/pmd/ruleset.xml, which records each threshold and the measurement behind it. Per method: cognitive complexity 15, cyclomatic complexity 10, 30 statements (NCSS), and 30 statements in one lambda body (a rule of the ruleset's own, since NCSS does not look inside lambdas); per class: 300 statements and cyclomatic complexity 80; 7 parameters; CPD at 100 tokens. There is no baseline: the code was refactored to pass. The one exemption is structural, the class-size limit for a wire record container (AcpSchema). Agent handlers are registered as data, soDefaultAcpAsyncAgent.start()(150 lines) is a loop;StreamableHttpAcpClientTransport(1,045 lines) is a facade over package-private classes for HTTP exchanges, routes, SSE streams and inbound delivery; the two sessions share their request plumbing. The class-cycle rule in acp-core'sArchitectureTestnow covers every package exceptjson(whose default-mapper lookup is a cycle by design), with theAcpAgent<->DefaultAcpAsyncAgentcycle gone. -
A bug-pattern gate fails
verify: SpotBugs 4.10.4 (spotbugs-maven-plugin 4.9.8.5; the 4.10 plugin needs Maven 3.8.9 and the wrapper is 3.8.6) runsspotbugs:checkon main sources in every module, on JDK 17 and JDK 21 alike, at effort Max, and fails on any finding of rank 9 or less (SpotBugs' "scariest" and "scary" bands). Rank, not confidence alone, is the gate: it weighs the pattern's severity with its confidence, so the long tail of rank 10 to 20 advice (exposed record arrays and collections, inner classes that could be static, serialization hygiene) does not fail the build. find-sec-bugs was evaluated and not added: it raised nothing at rank 9 or less, and its findings below that are the library's purpose (starting the configured agent process, writing JSON-RPC to a servlet response). The first run found three: two false positives, excluded one by one with their reasons inconfig/spotbugs/spotbugs-exclude.xml, and the Streamable HTTP SSE stream locking on its own monitor, which any code holding the stream could also take; it now locks a private object. -
The SpotBugs gate also fails on concurrency bugs below rank 9. SpotBugs ranks most multithreaded-correctness patterns 14 to 17 (inconsistent synchronization is 17), so a rank gate alone never sees them. The analysis now reports up to rank 20, and
config/spotbugs/spotbugs-include.xmlselects what fails the build: every pattern of rank 9 or less, plus a listed set of concurrency patterns at any rank (inconsistent synchronization, wait/notify and explicit-lock misuse, broken double-checked locking,volatileincrements, locking on the wrong object, mutable servlet fields), each group with its reason, and the concurrency patterns left out with theirs. The code base had no such finding. One analysis serves both: a secondcheckexecution with its own filter does not work, sincecheckanalyses with the plugin-level configuration and the second execution would pass on a report never written. -
A coverage gate fails
verify: JaCoCo'scheckrequires each module's line and branch coverage to stay at or above floors declared in itspom.xml(jacoco.minimum.line,jacoco.minimum.branch). Each floor is the coverage measured from a cleanverify, less a margin of 2 points, rounded down to a whole percent: line / branch floors are acp-core 82 / 70, acp-agent-support 85 / 76, acp-streamable-http-jetty 86 / 76, acp-json-jackson2 98 / 60, acp-json-jackson3 94 / 60 and acp-test 76 / 38; acp-annotations has no executable code. Each module counts only its own classes, exercised by its own tests (and, in acp-core, its integration tests); the JSON modules run acp-core's contract suite but count only their mapper classes. Nothing is excluded,AcpSchema's records included: they are covered like the rest (80 % of their lines). The parent's default floor is full coverage, so a new module fails until it declares measured floors. The per-module HTML report, now written inverifyso it shows what the gate checks, is at<module>/target/site/jacoco/index.html. The measurement found code no test in its own module reached, now tested:RemoteAcpConnection, the per-connection core of the remote agent transports (0 %, exercised only through acp-streamable-http-jetty); the WebSocket client transport's listener (fragmented and malformed frames, server close, socket errors, failed sends); the stdio client transport's reader and writer, which only a child JVM had run; and in acp-agent-support the fork, session-config and disable-provider handlers,Monoreturn values, and the errors a client gets when a handler cannot be invoked or answered (which found the bug above). acp-core moved from 77 % to 84 % of lines, acp-agent-support from 70 % to 88 %.
0.18.0 - 2026-09-25
Remote agents: the Streamable HTTP and WebSocket transport from the ACP RFD, on plain http:// and
https://, plus the fixes found reviewing it, and a choice of Jackson 2 or Jackson 3. Three changes
need attention when upgrading: acp-core no longer contains a JSON implementation, so a project
that depends on acp-core alone must add a JSON module; building a second client on an
already-connected transport now fails at construction; and JacksonAcpJsonMapper built from a bare
new ObjectMapper() is now strict about unknown fields.
- Streamable HTTP transport (RFD
streamable-http-websocket-transport),
by @kamikaz1k (#7).
StreamableHttpAcpClientTransportinacp-coretalks to a remote agent over HTTP POST with SSE response streams (one connection stream plus one stream per session,Acp-Connection-Id/Acp-Session-Idheaders,DELETEto close); the new moduleacp-streamable-http-jettyprovidesStreamableHttpAcpAgentTransport, a Jetty listener that serves HTTP/SSE and a WebSocket upgrade on the same path and hosts one agent per remote connection throughAcpAgentFactoryandRemoteAcpConnection. Header names and path match the TypeScript and Rust SDKs. Each SSE stream is a mailbox: events sent while no subscriber is attached, or not yet written to one that went away, are delivered in order when the client reopens it. Closing a connection (DELETE) cancels its in-flight prompts. - Limits and keep-alive for the HTTP agent transport (
StreamableHttpAcpAgentTransportOptions): POST bodies are capped (16 MiB by default, 413 beyond), the WebSocket send queue and the number of provisionalsession/loadstreams per connection are bounded, a failedsession/loadleaves no provisional state, the SSE mailbox and backpressure limits are configurable, attached streams get a: keep-alivecomment every 15 s so proxies do not cut idle connections, and a new GET on a stream takes it over from a subscriber the server may not yet know is dead instead of fanning out duplicates. One HTTP/2 connection may hold 1,024 concurrent streams (maxConcurrentStreamsPerConnection), not Jetty's default 128: each attached SSE stream holds one for its lifetime, and at the limit Jetty sends GOAWAY, which drops every exchange on the connection. - HTTP/2 over plain
http://. The RFD requires HTTP/2, and localhost without TLS is a first-class deployment. Over cleartext the JDK client only offers the h2c upgrade on a request without a body, soinitialize, a POST, went out on HTTP/1.1.StreamableHttpAcpClientTransportnow sends a bodiless GET first onhttp://endpoints, so against a server that speaks h2c (the SDK's own does) every request, streams included, runs on HTTP/2. Against one that does not, the probe settles on HTTP/1.1 and later requests stop offering the upgrade, which some servers route to their WebSocket handler. - The Streamable HTTP client reconnects a dropped SSE stream. When the server or the network closes the connection stream or a session stream, the client reopens it with a short backoff instead of failing the transport; the server's mailbox delivers whatever was not yet written. It gives up, as before, when the server answers 404 (the connection is gone) or after three reconnects in a row that delivered nothing.
- Client sessions learn that their transport died.
AcpClientTransport.awaitTermination()(default: never) is implemented by the Streamable HTTP and WebSocket client transports;AcpClientSessionfails pending requests at once with the cause, and every later request, instead of waiting out the request timeout. StreamableHttpAcpServletis public and mountable in any Servlet 6 container (Spring Boot, Tomcat, Jetty, Undertow) at the application's own path, from a JSON mapper, anAcpAgentFactoryand optionalStreamableHttpAcpAgentTransportOptions; register it with async support. It owns its connections: the container'sinit()starts the SSE keep-alive anddestroy()closes every connection, cancelling in-flight prompts. It serves the HTTP/SSE profile; the WebSocket upgrade on the same path needsStreamableHttpAcpAgentTransport, which now mounts this servlet on its own Jetty server.- JSON modules:
acp-json-jackson2andacp-json-jackson3(#12). The Jackson 2 mapper moved out ofacp-coreintoacp-json-jackson2, andacp-json-jackson3addsJackson3AcpJsonMapper(packagecom.agentclientprotocol.sdk.json.jackson3) on Jackson 3 (tools.jackson, 3.1.5, the version Spring Boot 4.1 manages). Both read the same schema records, whose Jackson annotations Jackson 3 reads unchanged, and write the same bytes: the schema serialization suite and an exact wire-format test run against each. The Jackson 3 default mapper is lenient and logs ignored properties at DEBUG like the Jackson 2 one, and restores Jackson 2 behaviour where Jackson 3 changed a default that touches the wire (alphabetical property sorting,nullfor primitives, trailing content, enums viatoString()); a strictJsonMapperpassed tonew Jackson3AcpJsonMapper(...)is honoured. Proposed by @bengbengbalabalabeng (#12). - Deterministic JSON mapper selection.
AcpJsonMapperSupplierhas adefault int priority()(0);AcpJsonMapper.createDefault()uses the highest, ties broken by class name, instead of whichever supplier came first on the classpath. Jackson 3 is -100 and Jackson 2 -200, so with both modules present Jackson 3 wins, and an application's own supplier wins over both. The system propertyacp.json.mapper.suppliernames a supplier class explicitly. CancelNotificationcarries_meta.AcpSyncClient(AcpAsyncClient)is public: the supported way to have both APIs over one session.
-
Breaking:
acp-coreno longer contains a JSON implementation (#12). It depends onjackson-annotationsonly (for the schema records), not onjackson-databindorjackson-core.acp-agent-support,acp-test,acp-websocket-jettyandacp-streamable-http-jettydepend onacp-json-jackson2, so their users see no change. If you depend onacp-corealone, add one JSON module, otherwiseAcpJsonMapper.createDefault()fails with "No AcpJsonMapperSupplier found on the classpath":<dependency> <groupId>com.agentclientprotocol</groupId> <artifactId>acp-json-jackson2</artifactId> <!-- or acp-json-jackson3 --> <version>0.18.0</version> </dependency>
JacksonAcpJsonMapperandJacksonAcpJsonMapperSupplierkeep their package (com.agentclientprotocol.sdk.json) and names, so code that constructs them compiles unchanged once the module is on the classpath. Proposed by @bengbengbalabalabeng (#12). -
Single-turn enforcement is per logical session.
AcpAgentSessionkeyed its active-prompt lock per transport connection; over HTTP one connection carries many sessions, so it is now keyed bysessionId(hasActivePrompt(sessionId),getActivePromptSessionIds()), matching the Kotlin SDK. Stdio and WebSocket agents, which see one session per connection, behave as before. (#7, #9) -
Unknown-field policy moved from the schema to the mapper (#10). Every schema record carried
@JsonIgnoreProperties(ignoreUnknown = true), which made the SDK tolerate fields it does not know (deliberate: the spec adds fields between releases and a newer agent must keep working) but also defeated any consumer's strictObjectMapper, since a class-level annotation wins overFAIL_ON_UNKNOWN_PROPERTIES. The annotations are gone. The default mapper,JacksonAcpJsonMapper.defaultObjectMapper(), is lenient and logs each ignored property at DEBUG so spec drift is observable; a consumer who passes a strict mapper toJacksonAcpJsonMappernow gets strict behaviour. If you constructJacksonAcpJsonMapperwith a barenew ObjectMapper(), you now get Jackson's default, which fails on unknown fields: start fromdefaultObjectMapper()instead. Unknown fields are not routed into_meta, which has its own spec-defined meaning. Reported by @KallivdH. -
One shared timeout scheduler. Every
AcpClientSessionandAcpAgentSessioncreated its own scheduled thread pool for request timeouts; over the Streamable HTTP transport, which hosts one agent session per remote connection, that was one idle thread per connection. Timeouts now run on a single library-owned daemon timer (AcpSchedulers.timeouts()). -
The scheduler-hygiene test (
SchedulerBestPracticesTest) now scans every module's production sources, not onlyacp-core.
WebSocketAcpAgentTransport(acp-websocket-jetty) is deprecated for removal. It serves a single WebSocket client.StreamableHttpAcpAgentTransport(acp-streamable-http-jetty) serves the WebSocket upgrade on the same path for any number of clients, one agent per connection, plus the Streamable HTTP profile;WebSocketAcpClientTransportclients connect to it unchanged.
WebSocketAcpAgentTransportwrote each frame without waiting for the previous one to complete; Jetty allows one outstanding write per WebSocket session. Frames are now written one at a time, each after Jetty's completion callback, and a failed write is reported without ending the outbound stream.- A second client on an already-connected transport now fails at construction. A transport
instance carries exactly one session.
StdioAcpClientTransport.connect()had no once-only guard (the WebSocket and agent transports did), so a secondAcpClient.async(transport)orAcpClient.sync(transport)on the same instance subscribed the transport's unicast inbound sink a second time — logged and dropped asSinks.many().unicast() sinks only allow a single Subscriber— and then started a second agent process, whose owner could never receive a response: its first request timed out after the fullrequestTimeout. That is the Spring Boot autoconfiguration failure reported in June 2026 (both anAcpAsyncClientand anAcpSyncClientbean built from one transport bean); it never depended on the Reactor or Boot version. Now the stdio transport refuses a secondconnect(), andAcpClientSession/AcpAgentSessionsurface a connect or start failure instead of dropping it: construction throwsIllegalStateExceptionwhen the transport refuses synchronously, and every later request fails immediately with the cause. - Second prompt on a session could be rejected or hang under CPU contention (#14). The agent
released its single-turn prompt lock in
doFinally, after the response had already reached the client. On a starved machine (taskset -c 0, busy CI runners) the client's next prompt arrived before that release and was rejected with-32000 There is already an active prompt execution; the rejection's emission then collided with the still-running response emission on the same sink (FAIL_NON_SERIALIZED), which the shipped transports silently dropped and the in-memory test transport turned into a fatal error for the agent, soAcpSyncClient.promptwaited out the fullrequestTimeout. The lock is now released before the response is published, on the success and error paths alike, and a handler that throws before returning itsMonono longer holds the lock forever. Response emission inStdioAcpAgentTransport,WebSocketAcpClientTransport,WebSocketAcpAgentTransportandInMemoryTransportPairis serialised the waysendMessagealready was, and a failed emission in the in-memory pair no longer terminates the agent. The reporter's repro is now a test, and CI runs the contention tests pinned to one CPU. Reported by @krickert. WebSocketAcpAgentTransportread its client session field twice around a null check while the socket'sonClosecould clear it, an occasionalNullPointerExceptionon close.- The agent session now reads
sessionIdfrom typedPromptRequest/CancelNotificationparams as well as from maps, so in-process transports get the right lock owner in logs and cancel matching.
0.17.0 - 2026-08-28
Wire-format correction. No public API change: every constructor and accessor is unchanged, and the ACP surface is identical to 0.16.1.
- Duplicate discriminator on the wire. Every polymorphic type declared its discriminator twice —
once as Jackson's
@JsonTypeInfoproperty on the interface and again as an explicit@JsonPropertyrecord component — so both wrote it.new TextContent("hello")serialised to{"type":"text","type":"text","text":"hello"}, and everysession/promptthe SDK sent carried a duplicate JSON key. Jackson-based peers accept duplicates and take the last, which is why this went unnoticed; a strict parser rejects the message outright. The discriminator components are nowAccess.WRITE_ONLYwithvisible = trueon the type info, so the type id is written exactly once and is still populated on deserialization — previously it deserialised tonull. 27 records across 6 hierarchies. No API change: every constructor and accessor is unchanged.
0.16.1 - 2026-08-24
Release-tooling patch. No protocol, dependency-closure or public API changes beyond a SLF4J patch bump: the ACP surface is identical to 0.16.0.
- Consumer-scoped SBOMs. Every module artifact now publishes a CycloneDX 1.6 SBOM (classifier
cyclonedx) rooted at that module and covering its compile and runtime scope only, so a consumer's dependency closure can be checked against what the artifact actually ships. - The release workflow commits the release version bump before tagging, so each
vX.Y.Ztag now points at a commit whose POMs carry that version.
- SLF4J 2.0.17 → 2.0.18.
0.16.0 - 2026-08-24
Maintenance dependency and release-tooling refresh. No protocol or public API changes: the ACP surface remains identical to 0.15.0.
- Jackson 2.21.5 → 2.22.2, Jetty 12.0.37 → 12.1.12, and Reactor 3.6.12 → 3.8.7.
- SLF4J, Logback, JUnit 5, Mockito, Maven build plugins, Central publishing, JaCoCo, and the optional OWASP dependency-check profile move to their current compatible stable releases.
- README installation examples now point to 0.16.0 and retain 0.15.0 in the release history.
0.15.0 - 2026-08-21
Correctness and supply-chain hygiene. No protocol or public API changes: the ACP surface is identical to 0.14.0, so this is a drop-in upgrade.
- Notification ordering. Incoming notifications were dispatched as independent
fire-and-forget subscriptions, so a handler doing any async work could observe them out of
order — visible with agents that stream many rapid
session/updatechunks. They are now serialized through a sink drained byconcatMap, preserving arrival order. Reported and fixed by @ljiro (#13, closes #11). - Notifications lost on graceful close. Following from the above,
closeGracefully()completed the notification sink and disposed the drain subscription in the same synchronous block, discarding anything still queued. BecauseAcpClient.SyncSpecwraps every syncsessionUpdateConsumerwithsubscribeOn(SYNC_HANDLER_SCHEDULER), sync clients always have async handlers, so a rapid burst could be lost entirely on close.closeGracefully()now waits for the drain to terminate before tearing the session down, bounded by the session'srequestTimeoutso a handler that never completes cannot hang shutdown.close()still interrupts immediately — the two methods now differ, as their names imply. - Notifications arriving after shutdown has begun are still dropped — a graceful shutdown stops accepting new work while draining what is queued, and JSON-RPC notifications carry no delivery guarantee — but the log severity now distinguishes that expected case (DEBUG) from overflow, zero-subscriber and non-serialized emission, which lose traffic on a live session (ERROR).
- Jackson 2.21.2 → 2.21.5 and Jetty 12.0.14 → 12.0.37, clearing 17 known advisories
(5 high severity) reported against the published dependency closure. Both reach consumers as
compile-scope transitives of
acp-coreandacp-websocket-jetty.
LICENSEis now the verbatim Apache License 2.0. The previous file was a paraphrase: it omitted section 6 (Trademarks) entirely, renumbered the sections that follow, rewrote the section 2 copyright grant, and narrowed theLicensorandWorkdefinitions. Automated license detection classified the repository asNOASSERTIONwhile every published POM declared Apache-2.0.LICENSEand a newNOTICEare now packaged underMETA-INFin every module artifact.- Removed a redundant
<repositories>declaration from the published parent POM; consumers no longer inherit a repository definition with snapshots enabled.
- Integration tests now execute. Three
*ITclasses existed, but Surefire's default includes do not match*ITand no Failsafe plugin was configured, somvn verifysilently skipped them. - All GitHub Actions are pinned to commit SHAs.
HandlerExceptionTestno longer races the async dispatch with a fixed sleep.
Protocol currency: catching up to ACP spec v0.13.6 (June 2026). Supersedes the never-published 0.13.0 (its content ships here).
logoutmethod (AcpAsyncClient/AcpSyncClient.logout,@Logout, agent handler) — clears stored credentials.session/deletemethod (deleteSession,@DeleteSession, agent handler) — permanently deletes a stored session; gated on thesessionCapabilities.deletecapability.additionalDirectoriesonsession/new,session/load,session/resume,session/forkrequests and onSessionInfo— extra workspace roots beyondcwd.- Per-chunk
messageIdonAgentMessageChunk,AgentThoughtChunk,UserMessageChunk, plussendMessage(text, messageId)/sendThought(text, messageId)convenience overloads onPromptContextandSyncPromptContext. - Provider configuration methods (
providers/list,providers/set,providers/disable), marked@UnstableAcpApi: client methodslistProviders/setProvider/disableProvider, agent handlers +@ListProviders/@SetProvider/@DisableProvider, theProviderInfo/ProviderCurrentConfig/ProvidersCapabilitiestypes, and aproviderscapability onAgentCapabilitiessurfaced viaNegotiatedCapabilities.supportsProviders(). sessionCapabilities.deleteandsessionCapabilities.additionalDirectories, surfaced viaNegotiatedCapabilities(supports*/require*).
- Promoted the session config-option API to stable: removed
@UnstableAcpApifromSetSessionConfigOptionRequest/SetSessionConfigOptionResponse,SessionConfigOption,SessionConfigSelect,SessionConfigSelectOption,ConfigOptionUpdate, and@SetSessionConfigOption—session/set_config_optionandsession/set_modeare now in the stable ACP schema. Thebooleanconfig-option variant (SessionConfigBoolean) remains an unstable SDK extension. - Aligned Jackson to 2.21.2 (matches the agentworks-bom managed set / Spring Boot's jackson-bom).
- WebSocket transport maximum message size increased to 4 MB.
- The session-model API —
session/set_model(setSessionModel,@SetSessionModel, handler),SetSessionModelRequest/SetSessionModelResponse,SessionModelState,ModelInfo, and themodelsfield on the new/load/resume/fork session responses — is deprecated for removal. The spec removed it (June 2026, v0.13.5); expose model selection throughsession/set_config_optionwith a config option whosecategoryis"model"instead. Scheduled for removal in a future release.
- WebSocket client transport no longer echoes agent requests back to the agent.
0.9.0 - 2026-02-XX
- Pure Java implementation of Agent Client Protocol (ACP) specification
AcpSchema— complete protocol type definitions (sealed interfaces and records)AcpSyncClient— synchronous blocking clientAcpAsyncClient— reactive async client with Project ReactorAcpClientSession— low-level client session implementationStdioAcpClientTransport— stdio transport for launching agents as subprocessesWebSocketAcpClientTransport— JDK-native WebSocket client transport (no extra dependencies)AgentParameters— process configuration builder for agent launch
AcpSyncAgent— synchronous agent with blocking handlersAcpAsyncAgent— reactive agent withMono-returning handlersStdioAcpAgentTransport— stdio transport for agentsSyncPromptContext— convenience API for sending messages, reading files, requesting permissions- All handler types: initialize, newSession, loadSession, prompt, setSessionMode, setSessionModel, cancel
@AcpAgent— class-level agent annotation with name/version@Initialize,@NewSession,@LoadSession,@Prompt,@Cancel— handler annotations@SetSessionMode,@SetSessionModel— session configuration annotations@SessionId,@SessionState— parameter annotationsAcpAgentSupport— bootstrap and builder for annotation-based agents- Flexible method signatures with automatic parameter resolution
- Auto-conversion of return values (
String→PromptResponse,void→endTurn()) - Interceptor support for cross-cutting concerns
- Custom argument resolvers and return value handlers
NegotiatedCapabilities— capability negotiation between client and agent- Client capabilities: file read/write, terminal execution, permission requests
- Agent capabilities: load session, image content, slash commands
require*()methods that throwAcpCapabilityExceptionif unsupported
AcpProtocolException— structured JSON-RPC errors with codesAcpCapabilityException— capability not supportedAcpConnectionException— transport-level failures- Standard error codes via
AcpErrorCodes
- Stdio transport (client and agent)
- WebSocket client transport (JDK-native)
- WebSocket agent transport (Jetty-based,
acp-websocket-jettymodule) - In-memory transport pair for testing (
acp-testmodule)
InMemoryTransportPair— bidirectional in-memory transport for unit testsMockAcpClient— mock client builder with file content fixtures- Fast, deterministic testing without subprocess I/O
- Full SessionUpdate types: AgentMessageChunk, AgentThoughtChunk, ToolCall, ToolCallUpdateNotification, Plan, AvailableCommandsUpdate, CurrentModeUpdate
- MCP server configuration in session requests
_metaextensibility on all protocol messages- All StopReason values: END_TURN, MAX_TOKENS, REFUSAL, CANCELLED
- Maven Central Portal publishing configuration
- CI workflow with GitHub Actions
- 258 unit tests
- Integration tests with Gemini CLI
- Java 17 (LTS)
- Project Reactor 2023.0.12
- Jackson 2.18.2
- MCP JSON utilities 0.15.0-SNAPSHOT
- SLF4J 2.0.16