NewSessionResponse(String, SessionModeState, SessionModelState) was removed along with session/set_model, so any caller
built against the old three-argument constructor fails to link against 0.80.0, even though a
three-argument overload still exists (its third parameter is now List<SessionConfigOption>, a
different type). The sections below name every breaking change; this page is the complete list, not
a sample.
The SDK listener now binds localhost only, and checks browser origins
Two security fixes, both breaking for a deployment that relied on the old, open-by-default behavior. The listener binds the loopback interface by default.StreamableHttpAcpAgentTransport set a
port and no host on its Jetty connector, so it listened on every network interface; the endpoint has
no authentication of its own, so anyone who could reach the machine could start an agent. It now binds
127.0.0.1, and ::1 too where the machine has IPv6, unless told otherwise. Remote exposure is an
explicit opt-in: StreamableHttpAcpAgentTransportOptions.builder().host("0.0.0.0") (or a specific
address), spring.acp.agent.transport.http.listener.host (Spring Boot, non-web applications),
acp.agent.transport.http.listener.host (Micronaut), or transport.http.listener.host in
AcpAgentSettings.
Servers a framework already runs (a Spring MVC servlet container, Quarkus) keep that framework’s own
bind settings (server.address, quarkus.http.host); this change doesn’t affect them. Migration:
a deployment that reached the listener from another machine or a container sets the host to
0.0.0.0, ideally behind a proxy that authenticates.
Browser requests from a foreign origin are refused (403). Any web page the user had open could
previously POST to, or open a WebSocket to, an agent listening on localhost (a cross-site request,
or DNS rebinding). Every host now checks the Origin header, on HTTP requests and on the WebSocket
handshake: a request with no Origin header (any non-browser client, including this SDK’s own client
and most IDEs) is served, and so is one from http(s)://localhost, 127.0.0.1, or [::1] on any
port; any other origin gets 403 unless it’s explicitly listed. The rule is in the shared endpoint
code, so the servlet, the SDK’s own listener (HTTP and WebSocket), and Quarkus’s own Vert.x route all
apply it. New:
StreamableHttpAcpAgentTransportOptions.Builder.allowedOrigins(Collection<String>) (* allows any
origin, which disables the protection) and isOriginAllowed(String);
spring.acp.agent.transport.http.allowed-origins, acp.agent.transport.http.allowed-origins
(Micronaut), quarkus.acp.agent.transport.http.allowed-origins, and transport.http.allowed-origins
in AcpAgentSettings. Migration: a browser application served from another origin that talks to
the agent directly lists that origin.
Spring Boot support moved into the SDK
acp-spring-boot-starter and acp-spring-boot-autoconfigure are now modules of the ACP Java SDK
itself, built and versioned together with it, replacing the separate spring-ai-community/acp-autoconfig
project:
The
spring.acp.* property names are unchanged. There is no relocation POM for this move: update the
coordinate and any imports directly rather than relying on a transitive redirect. See the
Spring Boot Starter page, now the canonical home for this
integration’s documentation.
Removed: session/set_model and the WebSocket-only agent transport
Two APIs that were deprecated for removal are gone.
session/set_model and the session-model API. Deprecated for removal in 0.14.0; the ACP schema
1.9.1 no longer defines the method, its request and response, or the models field on session
responses. Removed: AcpSchema.METHOD_SESSION_SET_MODEL, SetSessionModelRequest,
SetSessionModelResponse, SessionModelState, ModelInfo; the models component of
NewSessionResponse, LoadSessionResponse, ResumeSessionResponse, and ForkSessionResponse;
AcpAgent.SetSessionModelHandler, SyncSetSessionModelHandler, and the builders’
setSessionModelHandler; AcpAsyncClient.setSessionModel and AcpSyncClient.setSessionModel; the
@SetSessionModel annotation and SetSessionModelRequestResolver; the method’s Streamable HTTP
routing entries. An agent built on 0.80.0 no longer answers session/set_model: a peer that sends
it gets “method not found”.
Migration: expose model selection as a session config option whose category is "model", and
change it with session/set_config_option (setSessionConfigOptionHandler,
@SetSessionConfigOption, AcpAsyncClient.setSessionConfigOption). This is the same mechanism
already used for session modes (category: "mode") and reasoning level (category: "thought_level").
The acp-websocket-jetty module and WebSocketAcpAgentTransport. Deprecated for removal in
0.18.0; the module held only that class, a WebSocket agent transport that served a single client.
Migration: depend on acp-streamable-http-jetty and serve agents with
StreamableHttpAcpAgentTransport, which accepts WebSocket upgrades at /acp (and the Streamable
HTTP profile on the same path) and creates one agent per connection through an AcpAgentFactory:
WebSocketAcpClientTransport (acp-core) is unchanged and connects to it as before; only the
agent-side, single-client transport is gone.
Removed: @SessionState and @AcpExceptionHandler
Both annotations were declared in acp-annotations but nothing implemented them: a @SessionState
parameter failed every call with “No resolver for parameter”, and an @AcpExceptionHandler method
was never called. They are removed in 0.80.0, not merely deprecated.
Migration: keep per-session state in the agent itself, in a thread-safe map keyed by the session ID
(an @SessionId String parameter or the request’s sessionId()), and remove it in the
@CloseSession/@DeleteSession handler. Handle exceptions in the handler method itself, or in an
AcpInterceptor’s onError, and throw AcpProtocolException to answer with a specific JSON-RPC
error.
Annotation model: capabilities derived from handlers, and new return/parameter support
An annotated agent with no@Initialize method now gets one derived from the class: each handler
annotation advertises the capability its method needs, @Prompt’s image/audio/embeddedContext
attributes and @AcpAgent’s mcpHttp/mcpSse declare the rest, and agentInfo/authMethods come
from @AcpAgent’s own attributes. See
Clients and Agents in Java for the full shape. This is
additive (an existing @Initialize method keeps working, now overlaid on the derived response), but
it changes several things enough to call out directly:
@SetSessionModeor@SetSessionConfigOptionwithout a@NewSessionmethod is now a build error. Those two set state a session needs to exist first; an agent offering either must also offer a way to create the session with the options they set.- Every agent needs a
@Promptmethod (or a prompt handler, on the plain builder). Building one without it now fails withIllegalStateException, rather than silently answering “Method not found” for every prompt at runtime. - A handler-implied capability can no longer be withdrawn by an
@Initializemethod. Capabilities merge by OR: a handler’s presence always means “advertised,” even if the@Initializeresponse returned doesn’t mention it. If you relied on@Initializeto selectively suppress a capability a handler would otherwise imply, remove the handler instead (or gate it so it isn’t registered). - Misuse of the annotation model now fails at registration, not at the first call. Two handler
methods for one annotation, two handler annotations on one method, a parameter no resolver can
supply,
@SessionId/@ConfigId/@ConfigValueon a method that doesn’t have a session or config option to supply it from, and a request handler declaredvoidor returning the wrong type: all of these now throw when the agent is built, each naming the class, method, and fix, instead of behaving unpredictably (or not at all) once traffic arrives. MonoHandleris renamedAsyncValueHandler, and now also handlesCompletionStageand any single-value Reactive StreamsPublisher, not onlyMono. Code referencing the class by name (a customReturnValueHandlercomposed alongside it, say) needs the new name. APublisherthat emits more than one value now fails the call, where it previously wasn’t a supported return type at all.PromptContextandSyncPromptContextgain abstract methods:isCancelled()on both,onCancel(Runnable)onSyncPromptContext,whenCancelled()onPromptContext. A custom implementation of either interface, typically a test double, must implement the new methods.AcpAgentSupport.Builder.buildFactory()now throws iftransport(...)was also set (a listener supplies its own transport per connection, so the two are mutually exclusive), and bothbuild()andbuildFactory()throwIllegalStateExceptionif no agent bean was given at all. Previously these cases either weren’t checked or failed less clearly downstream.
Client initialize(InitializeRequest) is removed
A client’s capabilities and info are now set only on its builder, not on the initialize call.
Before: the builder’s clientCapabilities(...) was used only by the no-argument initialize();
the request-taking overload silently replaced it. A client that set capabilities on the builder and
then called initialize(new InitializeRequest(1, new ClientCapabilities())) advertised nothing,
while its own handlers enforced what the builder said: a real trap, not just an inconsistency.
Now:
AcpClient.async(...)/AcpClient.sync(...):.clientCapabilities(ClientCapabilities)(as before) and the new.clientInfo(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.
.clientCapabilities(...) on the builder and its
clientInfo to .clientInfo(...), then call initialize(); pass _meta through
initialize(1, meta) instead. A client that passed new InitializeRequest(1, null) now advertises
the default new ClientCapabilities() (no file system, no terminal) instead of omitting
clientCapabilities entirely; check code that relied on omission meaning “nothing negotiated.”
Session-update short constructors drop the discriminator
The short constructors of the session-update types no longer take the discriminator string: it could only ever be the variant’s own name, so writing it was noise.
The canonical constructors (discriminator first,
null or the variant’s own name) are unchanged;
SessionInfoUpdate(title, updatedAt) already had this shape before 0.80.0. Migration: drop the
first argument wherever one of these short constructors is called, e.g.
new AgentMessageChunk("agent_message_chunk", content) becomes new AgentMessageChunk(content).
Error codes now follow the ACP v1 schema
A prompt sent while the session already has an active prompt used to answer-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 now answers -32600 (invalid request), message There is already an active prompt on session <id>, data {"sessionId": "<id>"}.
AcpErrorCodes keeps only the codes the schema defines:
The error a request fails with when the peer answers with a JSON-RPC error is now one type,
com.agentclientprotocol.sdk.spec.AcpError, on both sides. It replaces the two identical nested
classes AcpClientSession.AcpError and AcpAgentSession.AcpError. Migration: import
com.agentclientprotocol.sdk.spec.AcpError instead. AcpProtocolException no longer converts to or
from JSONRPCError: use AcpSchema.JSONRPCError.from(exception) instead of
exception.toJsonRpcError(), and error.toException() instead of new AcpProtocolException(error).
Elicitation is stable
Promoted in protocol v1.7.0; its API is no longer@UnstableAcpApi. Three changes come with
stability:
- The elicitation capability’s modes are typed, and the no-argument
ElicitationCapabilities()constructor is removed.formandurlwereObject; they are nowElicitationFormCapabilitiesandElicitationUrlCapabilities. Migration:new ElicitationCapabilities()becomesElicitationCapabilities.formOnly();urlOnly()andformAndUrl()advertise the other modes. ElicitationActionis an open value, not an enum (see the next section): compare withequals, not==; replace aswitchwithif/elseor a switch overaction().value().StringPropertySchemaandMultiSelectPropertySchemahave only their canonical constructors, which end with_meta. Migration: passnullas the last argument, or useStringPropertySchema.text(...)andsingleSelect(...).
createElicitation fails with AcpCapabilityException when the client did not advertise the
request’s mode (before, any elicitation object, even {}, let both modes through), and a client
with a typed createElicitationHandler answers a request for an unadvertised mode with -32602
without calling the handler. Migration: advertise the modes your handler supports in
clientCapabilities.elicitation.
Open value types replace six enums
StopReason, ToolCallStatus, PermissionOptionKind, PlanEntryStatus, PlanEntryPriority, and
Role are now records over the wire string instead of enums, so an unknown value from a newer peer
no longer fails the whole message. The constants keep their names (StopReason.END_TURN); of(String)
resolves a known value; an unknown value is kept and written back.
ElicitationAction (ACCEPT, DECLINE, CANCEL) follows the same rule. ToolKind is unaffected:
it stays an enum and reads an unknown kind as OTHER, the schema’s catch-all; it gains value() and
of(String) for symmetry.
Constructor signatures changed
Several canonical constructors gained or reordered components. Each example below shows the canonical (longest) constructor; shorter convenience constructors are listed where they still work unchanged.NewSessionResponse,LoadSessionResponse,ResumeSessionResponsetakeconfigOptionsbefore_meta.new NewSessionResponse(id, modes, null)andnew LoadSessionResponse(modes, null)still compile (thenullis now the config options), but a call that passed a_metamap in that position no longer compiles. Migration: pass(id, modes, configOptions, meta)or(modes, configOptions, meta).ToolCall,ToolCallUpdate,ToolCallUpdateNotificationtakenameaftertitle. Migration: insert the tool’s name, ornull, after the title argument.AuthMethodis an interface; constructAuthMethodAgent. The schema makesAuthMethoda union. Migration:new AuthMethod(id, name, description)becomesnew AcpSchema.AuthMethodAgent(id, name, description);id(),name(), anddescription()stay on the interface.ClientCapabilitiestakessessionandauthafterterminal, in schema order. Migration:new ClientCapabilities(fs, terminal, elicitation, meta)becomesnew ClientCapabilities(fs, terminal, session, auth, elicitation, meta)(both may benull).AgentCapabilitiestakesauthafterpromptCapabilities. Migration:new AgentCapabilities(loadSession, session, mcp, prompt, providers, meta)becomesnew AgentCapabilities(loadSession, session, mcp, prompt, auth, providers, meta)(authmay benull).- Union records write their own discriminator and reject a wrong one. A canonical constructor
now turns
nullinto the record’s own wire name and throwsIllegalArgumentExceptionfor any other value (new AgentMessageChunk("agentMessage", ...)used to be silently corrected). Migration: pass the variant’s wire name (e.g."agent_message_chunk") ornull, or use the convenience constructors, which already do this. An exhaustiveswitchover a union’s known records needs a branch for its newUnknown*record (below). _metawas added to 48 records; old-signature constructors are kept except where noted above.
Session config options: flat or grouped
A select config option’soptions() now returns SessionConfigSelectOptions, either
UngroupedSelectOptions or GroupedSelectOptions: the schema allows a flat list or a list of
groups, and a grouped list used to fail to read. Migration: select.options() used as a list becomes
select.options().allOptions() (flattens across groups), or match on the two records directly. The
canonical constructor takes SessionConfigSelectOptions.ungrouped(list) or grouped(groups); the
(id, name, currentValue, List<SessionConfigSelectOption>) constructor is unchanged.
Forward compatibility: unknown variants no longer fail the message
A newer agent’ssessionUpdate type, an unknown content block, tool call content type, config
option type, or permission outcome used to fail the whole message, which the client then dropped
with an error log. Each union now reads an unknown variant as its own Unknown* record
(UnknownSessionUpdate, UnknownContentBlock, UnknownToolCallContent,
UnknownSessionConfigOption, UnknownPermissionOutcome), keeping the discriminator and every other
field, and writes it back unchanged. Code that does not know the variant can simply ignore it; code
with an exhaustive switch needs the new branch.
session/cancel no longer ends the prompt turn
This is a behavior change, not just a signature change, and it affects anything that cancels a
prompt and then sends another.
Before: the agent session freed 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.
Now: ACP v1 says the agent may still send session/updates after the cancel and must answer the
original session/prompt with stop reason cancelled, and that the client may send another prompt
only once that answer arrives. A prompt sent in between is rejected with -32600 (invalid request),
like any prompt during an active turn. The turn ends when the cancelled prompt’s answer is
published, the handler fails, the request is cancelled outright, the session closes, or the cancel
grace period passes.
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 reason cancelled itself. Before, a handler that never answered a cancelled
prompt kept its session busy indefinitely. cancelGracePeriod(Duration.ZERO) restores the old
behavior exactly (no grace period, no self-answer).
Migration: clients send the next prompt only after the cancelled one has answered (the SDK client
sends nothing on its own after cancel), and agent prompt handlers must answer a cancelled prompt
rather than assume the session is already free.
$/cancel_request, both directions
Separately from session/cancel, a request whose caller gives up now tells the peer. Disposing a
request’s Mono before the response arrives, or its timeout firing (60s default on both sides), sends
$/cancel_request {"requestId": ...} to the peer once; a peer that honors it
answers -32800 and the caller’s Mono is cancelled at once regardless. Migration: raise
requestTimeout for long prompts if the old behavior (agent keeps running after a client timeout)
is load-bearing anywhere, since 0.80.0 now actually stops the work.
Lifecycle renames, sync exceptions and timeouts
Verified against the CHANGELOG at the exact commit that introduced these changes (74fa003), not
just a relayed summary.
Lifecycle names line up, and both sync facades close gracefully
AcpSyncAgent.await()is renamedawaitTermination(), matchingAcpAsyncAgentand the transports. Migration: rename the call.AcpSyncAgentandAcpAgentSupportimplementAutoCloseable, andclose()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). For the old immediate close, callagent.async().close(). NewAcpSyncClient.async()returns theAcpAsyncClientit blocks on. Migration: use try-with-resources on the sync agent and the annotated agent, the same pattern already shown forAcpSyncClient.StdioAcpClientTransport.awaitForExit()is renamedawaitProcessExit(), so it doesn’t sit besideawaitTermination()(which completes when the transport ends, not the process). Interrupted, it now throwsCancellationException(interrupt flag kept) instead of a plainRuntimeException(interrupt flag cleared). Migration: rename the call; catchCancellationExceptioninstead.
The sync API’s exceptions change shape
Breaking: the sync API throwsAcpTimeoutException for a timeout, and CancellationException
when interrupted, instead of Reactor’s internal Exceptions$ReactiveException. Every blocking method
of AcpSyncClient, AcpSyncAgent, and SyncPromptContext used to let a request timeout escape as
reactor.core.Exceptions$ReactiveException (cause TimeoutException); both now throw
com.agentclientprotocol.sdk.error.AcpTimeoutException (an AcpException), whose cause is the
TimeoutException. A blocked call whose thread is interrupted (the SDK interrupts a sync handler to
cancel it) throws java.util.concurrent.CancellationException and leaves the interrupt flag set. The
async API is unchanged (its Mono still fails with the TimeoutException itself). Migration: replace
catch (RuntimeException e) { if (e.getCause() instanceof TimeoutException) ... } (or
Exceptions.unwrap(e)) with catch (AcpTimeoutException e), and catch CancellationException where
you handled an interrupt. See Errors for the full exception picture.
A client’s prompt is no longer bounded by the request timeout
Breaking (behavior): a prompt’s answer comes only at the end of its turn, so the request timeout (60 seconds by default, the same on every builder) used to cancel every turn that ran longer, sending the agent$/cancel_request and failing the call with a TimeoutException. session/prompt now
waits for the
end of the turn however long it takes; every other request keeps the request timeout. Migration: to
keep a bound on turns, set .promptTimeout(Duration.ofMinutes(10)) on the client builder (any
positive duration; it fails the call with a TimeoutException and sends $/cancel_request, as
before); code that raised requestTimeout only to let long turns finish can drop that workaround. In
Spring Boot, spring.acp.client.prompt-timeout; in Micronaut, acp.client.prompt-timeout; in
Quarkus, quarkus.acp.client.prompt-timeout (unset by default on all three).
sendSessionUpdate drops the session ID
Breaking: PromptContext.sendSessionUpdate(update) and SyncPromptContext.sendSessionUpdate(update)
replace the two-argument sendUpdate(sessionId, update) (0.18.0’s name for the method, before the
session-update vocabulary unified on sendSessionUpdate below).
The context already belongs to one prompt’s session, so the ID was redundant (and a wrong one
silently sent the update to another session). Migration: drop the first argument and use the new
name, context.sendUpdate(sessionId, update) becomes context.sendSessionUpdate(update); to update a
different session from outside its own prompt handler, call
AcpAsyncAgent.sendSessionUpdate(sessionId, update) (or AcpSyncAgent.sendSessionUpdate) instead,
which keeps the session-id parameter. A custom PromptContext/SyncPromptContext implementation (a
test double) implements the new one-argument method.
Builders reject a null handler and a duplicate registration
Breaking: the typed setters ofAcpAgent.AsyncAgentBuilder and AcpAgent.SyncAgentBuilder used
to accept null (every request for that method was then answered -32603), and registering a
handler for a method a second time silently replaced the first. A null handler now fails with
IllegalArgumentException at the setter; a second registration for the same method (protocol or
extension) fails with IllegalStateException naming the setter, for example “A handler for
session/prompt is already registered; promptHandler was called twice on this builder.” Migration:
register each handler once; decide which handler to use before calling the setter, not after.
handlerExecutor replaces the static handler pools; two public constants are gone
Breaking: sync handlers (and a sync client’s session-update consumers) used to run on static,
unbounded, JVM-wide cached thread pools exposed as AcpAgent.SYNC_HANDLER_SCHEDULER and
AcpClient.SYNC_HANDLER_SCHEDULER. New handlerExecutor(ExecutorService) on AcpAgent.SyncAgentBuilder,
AcpClient.SyncSpec, and AcpAgentSupport.Builder (for annotated agents) lets you pass an executor
of your own, for example Executors.newVirtualThreadPerTaskExecutor(), or a framework’s own worker
pool (Quarkus’s, Micronaut’s TaskExecutors.BLOCKING); the SDK cancels a handler by cancelling its
task (interrupting the thread) and never shuts the executor down itself. Without one, handlers keep
running on the same daemon pools as before, so this is additive for most applications. AcpAgent.DEFAULT_REQUEST_TIMEOUT
is also removed; the default (60 seconds) is just stated on requestTimeout’s own Javadoc now.
Migration: drop any reference to AcpAgent.SYNC_HANDLER_SCHEDULER/AcpClient.SYNC_HANDLER_SCHEDULER
(use your own scheduler, or handlerExecutor(...) to choose where handlers run), and replace
AcpAgent.DEFAULT_REQUEST_TIMEOUT with Duration.ofSeconds(60) directly. See
Clients and Agents in Java for where this fits into the
bigger annotation/builder picture, and the framework integration pages for the executor each one
recommends.
askPermission/askChoice now announce a tool call
Breaking (behavior): askPermission and askChoice used to ask about a tool-call ID the client
had never been told about (a client that looked it up in its own tool-call list found nothing), and
askPermission always labeled the action edit. Both now announce the tool call first with a
tool_call session update (status pending), ask about it, and settle it with a tool_call_update
(completed once answered, failed if the client cancelled the request): session-update consumers
now see two more updates per call. askPermission(action) uses kind other; a new overload,
askPermission(action, ToolKind kind), sets it explicitly, for example
askPermission("Run the tests", ToolKind.EXECUTE). Migration: none needed to keep working; to keep
the old edit kind, call askPermission(action, ToolKind.EDIT). Separately, askChoice used to parse
the chosen option ID as an index and fail with NumberFormatException or
ArrayIndexOutOfBoundsException on an unexpected answer; it now fails clearly, naming the ID (reason
unoffered-option, as AcpError). Implementations of PromptContext/SyncPromptContext (test
doubles) add the new askPermission overload.
One error type and stricter client builders
Verified against the CHANGELOG and the code at the commit that introduced these changes (bc11736).
A caller catches one type, AcpError, for every failed request
Breaking: the two-type split this guide and Errors once described
(AcpError for a peer’s own error; AcpProtocolException for the SDK’s own local rejection of a
malformed response) collapses to one type a caller ever catches. A missing required field, a response
with no result (reason missing-result), a result that can’t be read as the method’s type (reason
unreadable-result), and askChoice answered with an option it never offered (reason
unoffered-option, see above) all now fail with AcpError, code -32603, getData() a map with
that reason and the request’s method. AcpTimeoutException, CancellationException, and
AcpCapabilityException keep their own types; they were never JSON-RPC errors in the first place.
Migration: replace catch (AcpProtocolException e) around a client or agent call with
catch (AcpError e); AcpProtocolException remains exactly what a handler throws to answer with an
error, it just no longer crosses to the caller’s side on its own. See
Errors for the full picture, including the two exceptions, now also
living on that page, that mean a call was never sent at all: AcpCapabilityException and
IllegalStateException, both covered next.
A client initializes first, and calls only what the agent advertised
Breaking (behavior): everyAcpAsyncClient/AcpSyncClient call but initialize itself now fails
with IllegalStateException (“Call initialize() first: the agent has not answered initialize yet”)
until the agent has answered; it used to be sent regardless, relying on the agent to reject it.
loadSession, listSessions, closeSession, deleteSession, resumeSession, forkSession,
logout, and the providers/* calls additionally fail with AcpCapabilityException (naming the
capability, for example sessionCapabilities.close) when the agent’s initialize answer didn’t
advertise it, the same check the agent side already applies to the client’s own capabilities. Neither
check sends anything. The check runs when the call is subscribed, not when it’s built, so
client.initialize().then(client.newSession(request)) works without waiting for initialize to
complete first; extension calls (sendExtRequest, sendExtNotification) are outside ACP’s lifecycle
and skip both checks. Migration: call initialize() first; check getAgentCapabilities() before an
optional call, or catch AcpCapabilityException. A test agent with its own initializeHandler must
advertise the capabilities its handlers serve, or drop the handler (a builder agent’s default
initialize advertises them for you).
A client’s advertised capabilities must have their handlers
Breaking (behavior):AcpClient.AsyncSpec/SyncSpec.build() now throws IllegalStateException
(“The client advertises capabilities it has no handler for: …”) when the client capabilities
advertise fs.readTextFile without readTextFileHandler, fs.writeTextFile without
writeTextFileHandler, terminal without all five terminal handlers, or an elicitation mode without
createElicitationHandler. Such a client used to build, and the agent’s requests were answered
“Method not found” at runtime instead of failing at startup. A handler for a capability the client
does not advertise now also logs one WARN, since an SDK agent will never call it. The Spring Boot,
Micronaut, and Quarkus client beans add the specific property that advertised the capability to the
message. Migration: register the handlers (in an AcpClientCustomizer when the capabilities come from
properties), or stop advertising the capability. The test MockAcpClient (acp-test) no longer
advertises terminal, which it never served; a test that checked for it being advertised needs
updating.
Client builders reject a null, duplicate, or misdirected handler too
Breaking: the same discipline the agent builders already enforce now applies toAcpClient.AsyncSpec/SyncSpec. Registering a second handler for one method (a typed setter such as
readTextFileHandler called twice, requestHandler/notificationHandler for a method that already
has one, a raw handler and the typed setter for the same method) throws IllegalStateException
(“A handler for <method> is already registered on this builder; <setter> cannot register a second
one”), instead of silently replacing the first; sessionUpdateHandler stays additive. A raw
requestHandler(method, ...)/notificationHandler(method, ...) for a method the SDK already models
(fs/read_text_file, the five terminal/* methods, session/request_permission,
elicitation/create, session/update, elicitation/complete) now throws IllegalArgumentException
naming the typed setter to use instead: the raw handler bypassed the typed params and, for elicitation,
the mode check. SyncSpec.notificationHandler is now a blocking Consumer<Object>, run on the sync
handler threads like the sync builder’s other handlers, instead of the async NotificationHandler
returning a Mono. Migration: register each handler once; use the named typed setter for a modelled
method; on the sync builder, drop the Mono from a raw notification handler body.
SessionConfigSelect.Builder.build() checks the option’s value
Breaking: it now throws IllegalStateException when the options (across every group) are empty,
or when currentValue isn’t the value of one of them, instead of building an option no client could
display correctly. The record’s canonical constructors stay lenient, since they also read other
agents’ options over the wire, which might (by a bug on their side) be shaped this way; only the
builder, used to construct your own options, enforces it. Migration: pass at least one option, and set
currentValue to one of their values. See Session Config Options.
Two new conveniences
- One default request timeout, 60 seconds, everywhere. The client builders
(
AcpClient.sync/async) andAcpAgentSupport.Builderdefaulted to 30 seconds while the agent builders defaulted to 60; all four now default to 60. 60 seconds because an agent’s own 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. The Spring Boot (spring.acp.client.request-timeout) and Micronaut (acp.client.request-timeout) properties default to 60s too; Quarkus’squarkus.acp.client.request-timeout, left unset, already followed the SDK default either way. Migration: none needed; to keep the old 30-second client bound, setrequestTimeout(Duration.ofSeconds(30))(or the property to30s). defaultSessionUpdateHandler(..)onAcpClient.AsyncSpec/SyncSpec: a handler that runs only while nosessionUpdateHandlerhas been added, so a framework can supply a default (logging each update at DEBUG, say) that the application’s own handler replaces outright rather than running beside. The Spring Boot, Micronaut, and Quarkus client beans now register their DEBUG logging this way.sessionUpdateHandleritself stays additive, as before. (NameddefaultSessionUpdateConsumerandsessionUpdateConsumerbefore the rename below.)- 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; prefer them in new code wherever the full form adds nothing.
acp-integration: a shared layer under all three framework integrations, and what moves because of it
Verified against the CHANGELOG and the code at the commit that introduced it (c5084f5). Spring Boot,
Micronaut, and Quarkus support are now thin layers over one new module,
com.agentclientprotocol.sdk.integration (acp-integration): settings, transport selection,
discovery, agent/client assembly, and lifecycles, implemented once and shared. See
Concepts: the framework-neutral layer
for what it actually contains; this section covers what moved or changed as a result, framework by
framework.
AcpClientCustomizer and the transport-type enum move
Breaking: both types are now framework-neutral, in com.agentclientprotocol.sdk.integration,
replacing a separate copy (and a separate TransportType enum) in each framework’s own package.
Migration: fix the import to com.agentclientprotocol.sdk.integration.AcpClientCustomizer and
com.agentclientprotocol.sdk.integration.AcpTransportType, wherever either was imported from a
framework-specific package.
Spring Boot: several property changes
Migrating fromorg.springaicommunity:acp-spring-boot-starter 0.12.0, or from an earlier 0.80.0
candidate build of this same starter, several things change beyond the coordinate move already
described above:
spring.acp.agent.request-timeoutandspring.acp.client.request-timeouthave no default of their own (an earlier candidate’s60srow in each properties table was the SDK’s default showing through, not Spring’s own): unset, both keep following the SDK’s one default request timeout, so a future SDK default change would reach these properties automatically.- Several client transports configured with no explicit
transport.typenow fail at startup, naming them:"Several ACP client transports are configured [...]; choose one with spring.acp.client.transport.type."The stdio/websocket/http precedence order it used to pick silently is gone. - 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. They meant nothing in a servlet web application (which serves the endpoint on its own server andserver.port) even before this move; now they’re grouped under a prefix that makes that explicit. spring.acp.agent.transport.type=websocketis accepted, meaning the same ashttp: the endpoint takes WebSocket upgrades on its path either way.- New properties:
spring.acp.client.capabilities.elicitation-form,.elicitation-urland.boolean-config-options(the last needs no handler, unlike the others);spring.acp.agent.cancel-grace-periodandspring.acp.agent.max-prompt-duration; andspring.acp.agent.handler-executor, see below. ArgumentResolverandReturnValueHandlerbeans are now added to the agent, the same asAcpInterceptorbeans already were: register one as a plain@Beanand it’s picked up automatically, in bean order, with no further wiring.- 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’sapplicationTaskExecutoronly whenspring.threads.virtual.enabled=true(a virtual thread per handler); without virtual threads that executor is a pool of 8 threads by default, which would cap the prompts served at once, so the SDK’s own pool stays the default instead. See Spring Boot: Where handlers run. - Closing the client waits at most its request timeout plus 10 seconds, then closes it at once, bounded rather than left to whatever Spring’s own shutdown ordering happened to do.
- Everything else in this release’s breaking changes still applies: Spring applications compile against the SDK’s current API, same as any other consumer.
transport.http.port/...max-concurrent-streams-per-connection properties to
their .listener.* form; set transport.type explicitly wherever more than one transport property is
set; register ArgumentResolver/ReturnValueHandler beans the same way AcpInterceptor beans already
are, if you have any outside the autoconfiguration’s own discovery.
Micronaut: handler methods run on virtual threads on JDK 21+
Behavior change, not a property: 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. This is automatic, with nothing to configure, and deliberately not
TaskExecutors.BLOCKING (Micronaut’s I/O pool): that pool’s non-daemon threads would keep a stdio
application’s process alive past the point its context closes. See
Micronaut: Where handlers run.
Quarkus: the same transport strictness, and handlers on the ManagedExecutor
Breaking (behavior): several client transports configured with no explicit
quarkus.acp.client.transport.type now fail at startup, naming them, the same as Spring Boot and
Micronaut; it used to infer WebSocket, then HTTP, then stdio, silently picking the first one
configured. Migration: set transport.type explicitly wherever more than one transport property is
set.
Additive: the agent’s handler methods now run on the ManagedExecutor (Quarkus’ own worker pool,
with the application’s contexts propagated) instead of a second pool of the SDK’s; nothing to
configure. See Quarkus: Where handlers run.
Errors, interceptors and build-time checks
Verified against the CHANGELOG and the code at the commit that introduced these changes (08732ad).
The caller
side of errors is now at its final shape, covered in full on Errors; this
section is the migration summary, not a restatement.
Errors: a security fix, a new exception, and a hierarchy change
- A handler’s unexpected exception no longer sends its message to the peer. Any exception other
than
AcpProtocolExceptionescaping a handler used to answer-32603with that exception’s own message, which can carry paths, SQL, or credentials; it’s now answered-32603with the generic message"Internal error"on both sides, and the real exception is logged atWARNwith its stack trace on the side that handled it. AnAcpErrorthat escapes a handler is the one exception besidesAcpProtocolExceptionwith its own, unchanged treatment: it’s passed on as the answer, not flattened to-32603, since it’s already the peer’s own failure moving one hop further. Migration: a handler whose exception message the peer needs throwsAcpProtocolExceptionwith that message explicitly. AcpErrornow extendsAcpException. A multi-catchcatch (AcpException | AcpError e)no longer compiles; catchAcpExceptionalone, and ifAcpErrorneeds its own clause, put it before theAcpExceptionone. Migration: fix the multi-catch, and check the order of any adjacentAcpError/AcpExceptioncatch blocks.- A lost connection fails a request with
AcpConnectionException, on both sides, replacing a plainRuntimeException(a request pending when the transport ended) and anIllegalStateException(sent after that, or afterclose()). Migration: replacecatch (RuntimeException e)orcatch (IllegalStateException e)used to mean “the connection is gone” withcatch (AcpConnectionException e). AcpCapabilityException.toProtocolException()is removed. It answered-32600, a code the SDK made up for the case; the one refusal ACP actually specifies (an elicitation mode the client never declared) already answers-32602directly, without this method. Migration: a handler converting its own capability check to an error throwsnew AcpProtocolException(AcpErrorCodes.INVALID_PARAMS, e.getMessage(), e.getCapability()).
Interceptors
AcpInterceptor.onErrorcan answer with an error of its own, and now reaches only the interceptors whosepreInvokeactually ran. AnAcpProtocolExceptionthrown fromonErrorbecomes the response, with the original failure attached as suppressed; any other exception there is a fault in the interceptor and is answered-32603. Migration: anonErrorthat threw expecting to be ignored should log and returnnullinstead.- Breaking:
afterCompletiontakes the exception the call failed with, as a second parameter:afterCompletion(AcpInvocationContext context, Throwable ex),exnull on success. An override without@Overridekeeps compiling against the old one-argument signature but is silently never called again; add@Overrideto catch this at compile time, and add the parameter. - The default
session/newanswer now passes through the interceptors too. An annotated agent with no@NewSessionmethod used to answersession/newwithout going throughpreInvoke/postInvoke/onError/afterCompletion, unlike every other derived answer. Migration: an interceptor that should ignoresession/newcheckscontext.getAcpMethod().
Rejected at build or setter, instead of failing later or silently
- An
@ExtNotificationmethod that doesn’t returnvoidfails the build, naming the method: a notification gets no answer, so a returned value was silently dropped before. AcpAgentSupport.Builder.cancelGracePeriod(..)andmaxPromptDuration(..)reject a negative value at once, instead of storing it and failing later atbuild()(or, withbuildFactory(), failing every connection).AcpAgentSupport.Builder.interceptor(null)fails at once withIllegalArgumentException, asargumentResolver(null)andreturnValueHandler(null)already did.
additionalDirectories needs the capability, both ways
Breaking: the client now sends additionalDirectories only to an agent that advertises
sessionCapabilities.additionalDirectories; naming any when the agent doesn’t fails locally with
AcpCapabilityException, without sending the request. An annotated agent advertises it with
@AcpAgent(additionalDirectories = true), which needs a @NewSession method (the default answer
would silently drop the directories, which ACP forbids); a builder agent advertises it from its
initializeHandler. Migration: a client checks getAgentCapabilities().supportsAdditionalDirectories()
before naming directories; an annotated agent that reads them adds additionalDirectories = true to
@AcpAgent (an @Initialize method that already advertised them keeps working unchanged).
Two more fixes worth knowing about
- A prompt’s updates no longer follow its answer. Once a prompt has been answered, by its handler
or by the SDK itself (the cancel grace period or
maxPromptDurationpassed),sendUpdate(and the helpers built on it, such assendMessage) now drops anything sent afterward and logs it at DEBUG, without its content, instead of sending an update after the answer, which ACP forbids.sendSessionUpdateoutside a prompt context is unaffected. SessionCapabilitieswrites only objects, and reads only objects as advertised.Boolean.TRUEused to write"list": true, which the schema forbids (a session capability is an object or absent); it now writes{}. Reading used to count any non-null value, includingfalse, as advertised; only a JSON object does now. Migration: none for code using the builder or thesupportsX()accessors; code that read the raw JSON by hand should expect{}, nottrue.- A builder agent advertises
providersfor any provider handler, not onlyproviders/list: an annotated agent already did; a plain builder agent with only aproviders/setor/disablehandler used to advertise nothing, so the client refused those calls.
Micronaut: the three capability properties it was missing now exist
Fixed:acp.client.capabilities.elicitation-form, .elicitation-url, and
.boolean-config-options (default false) now exist, matching Spring Boot and Quarkus; the Micronaut
client configuration used to hard-code all three as not advertised. See
Micronaut: A client, from configuration.
Migration: a Micronaut client working around the gap with a customizer’s
spec.clientCapabilities(...) override can drop the workaround and use the properties instead.
Cancellation no longer needs an explicit answer
Verified against the CHANGELOG and the code at the commit that introduced these changes (2f75223).
- A prompt answers
cancelledoncesession/cancelwas received, whatever the handler returns or throws. Before, a handler that returned another stop reason within the grace period (or just letPromptResponse.endTurn()fall through) had that answer sent instead, and a handler that failed after a cancel was answered-32603, notcancelled. The agent session now sendscancelledregardless, keeping the handler’s_metawhen it did return a response; before a cancel, failures are still answered as before (only a cancellation, or anAcpProtocolExceptionthe handler threw itself, gets special treatment there). Migration: none needed to keep working; atry/catchwritten solely to turn a post-cancel failure intocancelled, or an explicit check-and-return ofStopReason.CANCELLEDfor the same reason, is now redundant rather than required. See Cancellation for the full behavior. - Builder agents answer
session/newwithout a registered handler.build()used to answer-32601without anewSessionHandler, so no client could open a session; it now answers withAcpSchema.NewSessionResponse.withGeneratedId()(a random UUID, no modes or config options), the same default an annotated agent without@NewSessionalready had.build()still requires a prompt handler; that remains the one ACP method with no default. Migration: none needed; anewSessionHandlerregistered explicitly still replaces the default. - The agent checks the client’s
terminalcapability for every terminal method, not onlycreate.terminal/output,terminal/wait_for_exit,terminal/killandterminal/releaseused to be sent regardless of whether the client advertisedterminal, for example with a terminal ID from a different connection. All five now fail locally withAcpCapabilityExceptionand send nothing, from the agent and from a prompt context’sexecute(...). Migration: none for a client that already advertisesterminalconsistently; code that relied on these four methods reaching the wire without the capability advertised needs to register the capability instead.
Client cancellation, agent-aware handlers, context.client() and capability builders
Verified against the CHANGELOG and the code at the commit that landed these changes (513f0ac, and
the seven commits immediately before it on the same branch).
Client-side prompt cancellation: prompt(request, CancellationSignal)
New, not breaking. AcpAsyncClient.prompt(request, stop) and AcpSyncClient.prompt(request, stop)
take a new com.agentclientprotocol.sdk.client.CancellationSignal; calling stop.cancel(), from any
thread, sends session/cancel for the prompt’s session once, and the prompt still returns the agent’s
answer (stop reason cancelled) with the turn’s updates. Cancelling after the answer sends nothing.
Before 0.80.0, stopping a turn and keeping its answer took
contextWrite(RequestCancellation.cancelWhen(trigger)), which no completion menu offered and the sync
client couldn’t use at all; RequestCancellation/contextWrite stays, for $/cancel_request on any
other request. Migration: none; prompt(request) is unchanged. To stop a turn and keep its answer,
replace prompt(request).contextWrite(RequestCancellation.cancelWhen(trigger)) with
prompt(request, stop) and call stop.cancel(). See Cancellation.
Builder handlers receive their agent
New, not breaking. Every typed request setter ofAcpAgent.async(..) and AcpAgent.sync(..)
except promptHandler has an overload taking an AgentAwareHandler<Request, Response> (async,
(request, AcpAsyncAgent agent)) or SyncAgentAwareHandler<Request, Response> (sync, (request, AcpSyncAgent agent)); a two-argument lambda picks it. The agent is the one build() returned, so a
session/load replay or a ConfigOptionUpdate after session/set_config_option no longer needs an
AtomicReference holding the built agent:
.setSessionConfigOptionHandler((request, agent) -> { agent.sendSessionUpdate(request.sessionId(), update); return response; }).
One-argument handlers are unchanged. The two interfaces are top-level types in
com.agentclientprotocol.sdk.agent. Migration: none; a handler that read the built agent from an
AtomicReference can take it as its second parameter instead:
.loadSessionHandler(request -> agentRef.get()...) becomes
.loadSessionHandler((request, agent) -> agent...).
Breaking, incidentally: AcpAgent.AsyncAgentBuilder and AcpAgent.SyncAgentBuilder are now
final. 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 with AcpAgent.async(transport) and AcpAgent.sync(transport), and wrap a builder rather
than subclass it.
The prompt context is split into its two layers: context.client()
Breaking. PromptContext and SyncPromptContext keep the convenience layer (sendMessage,
sendThought, sendSessionUpdate, readFile, tryReadFile (sync), writeFile, askPermission,
askChoice, execute, cancellation (isCancelled, whenCancelled/onCancel), getSessionId,
getClientCapabilities, and async() (sync)) directly on it, and the raw ACP requests move one step
down to the new context.client(): a SessionClient (async, Monos) or SyncSessionClient
(blocking) with readTextFile, writeTextFile, requestPermission, createTerminal,
getTerminalOutput, releaseTerminal, waitForTerminalExit, killTerminal, createElicitation and
completeElicitation. The calls themselves are unchanged (same request records, capability checks,
timeouts and cancellation). readFile no longer sits beside readTextFile, nor askPermission beside
requestPermission, in the completion menu. Migration: insert .client() before each raw call:
context.readTextFile(request) becomes context.client().readTextFile(request), likewise
writeTextFile, requestPermission, the five terminal methods, createElicitation, and
completeElicitation. A test double implementing PromptContext or SyncPromptContext drops those
ten methods and implements client(). See Agent-to-Client Calls.
One vocabulary for session updates
Breaking, no deprecated aliases. 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: AcpClient.AsyncSpec/SyncSpec.sessionUpdateConsumer(..) becomes
sessionUpdateHandler(..), defaultSessionUpdateConsumer(..) becomes defaultSessionUpdateHandler(..),
and PromptContext.sendUpdate(update)/SyncPromptContext.sendUpdate(update) becomes
sendSessionUpdate(update). Behavior and parameter types are unchanged, and so is the registration
error a raw notificationHandler("session/update", ..) gets, which now names the new setter.
Migration: rename every occurrence; there’s no bridging overload under either old name.
Client builders derive the advertised capabilities from the handlers
New, not breaking by default. A client builder withoutclientCapabilities(..) now advertises
fs.readTextFile and fs.writeTextFile for their handlers, terminal once 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 a createElicitationHandler. Before, it advertised nothing and only warned,
so an SDK agent refused to call the handlers. Merge rule: explicit wins. Capabilities set with
clientCapabilities(..) are sent as they are, nothing is derived, and the existing check applies (an
advertised capability without its handler fails build(); 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. Set capabilities explicitly
for what handlers cannot express: URL-mode elicitation, boolean config options, terminal
authentication, _meta. Migration: none for code that sets clientCapabilities(..). A client that
registered handlers without capabilities on purpose, so that agents would not call them, passes
clientCapabilities(new ClientCapabilities()).
Capability builders that lead to each choice
New, not breaking.ClientCapabilities.builder() gains readTextFile(), writeTextFile(),
terminal(), elicitationForm() and elicitationUrl(); AgentCapabilities.builder() gains
loadSession(), promptImage(), promptAudio(), promptEmbeddedContext(), mcpHttp() and
mcpSse(). Each sets one flag and keeps the others (and the nested record’s _meta), so
ClientCapabilities.builder().readTextFile().terminal().build() replaces
fs(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)) becomes .readTextFile().writeTextFile(), and
.loadSession(true) becomes .loadSession().
One framework-neutral ACP endpoint, and Spring MVC serving WebSocket too
Verified against the CHANGELOG and the code at the commit that landed it (11273b5).
The HTTP transport is split into three modules. acp-http-core (the endpoint,
AcpHttpEndpoint, and StreamableHttpAcpAgentTransportOptions), acp-http-servlet
(StreamableHttpAcpServlet, no Jetty dependency), and acp-streamable-http-jetty
(StreamableHttpAcpAgentTransport, now an embedded-Jetty launcher that runs the servlet host with
Jetty’s own 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 on acp-http-servlet instead of acp-streamable-http-jetty, and no
longer gets Jetty; an application using the SDK’s own listener keeps acp-streamable-http-jetty.
As of 0380b28, a servlet web application on Tomcat whose classpath still puts an older Jakarta
WebSocket API ahead of Tomcat’s own now fails at startup, naming the fix (acp-http-servlet, or
managing jakarta.websocket-api to 2.2.0), instead of starting cleanly and failing every WebSocket
send with NoSuchMethodError later, with clients hanging and nothing in the server’s log. The check
only runs when Tomcat’s own WebSocket support is on the classpath; Jetty and Undertow aren’t affected.
Breaking: StreamableHttpAcpServlet is now a host of AcpHttpEndpoint. It gained the
constructor StreamableHttpAcpServlet(AcpHttpEndpoint) and endpoint(); the routing, connection and
SSE classes it used internally moved to acp-http-core. A subclass overriding doGet, doPost or
doDelete no longer intercepts anything: service hands every request to the endpoint. Migration:
wrap the AcpHttpEndpoint instead of subclassing the servlet.
Spring MVC now serves Streamable HTTP, SSE, and WebSocket all on server.port. A servlet web
application’s ACP endpoint used to serve HTTP and SSE only, with no WebSocket upgrade; it now upgrades
WebSocket on the same path too, through the application’s own filter chain, so Spring Security rules
on /acp apply to the WebSocket handshake as well as HTTP, and the authenticated principal reaches
the endpoint (AcpHttpExchange.principal()). With server.compression.enabled,
text/event-stream is excluded from compression automatically. Migration: none to keep working;
a client that connected over websocket specifically, expecting it to fail against a servlet-mounted
agent, now succeeds instead.
Breaking: the WebSocket shutdown close code is 1001 (going away), not 1000. A client that
treated 1000 as the server’s shutdown signal should treat 1001 the same way.
Shutdown drains open streams. Closing the endpoint (servlet, listener, or Spring’s own
SmartLifecycle before graceful shutdown) now gives each open SSE stream a closing comment
(: shutting down) before completing it, and closes each WebSocket with 1001, so a framework’s
graceful shutdown phase never waits on them. Migration: none.
Fixed: a WebSocket client reliably sees 1001, not a dropped connection (1006). On Jetty (the
standalone listener, and a servlet on Jetty), the close frame is sent asynchronously; shutdown used to
complete once the agents had closed, and Jetty was then stopped before the frame went out. The drain
now waits for each WebSocket’s close to actually go out, on every host, bounded by the shutdown
timeout: a client that never answers the close holds shutdown no longer than that, after which it’s
logged at WARN and closed at once. Migration: none.
SSE responses carry X-Accel-Buffering: no, alongside the existing Cache-Control: no-cache, so
nginx and similar reverse proxies don’t buffer them. Migration: none.
The host contract is @UnstableAcpApi. AcpHttpEndpoint and the I/O adapter types a custom host
would implement (AcpHttpExchange, AcpHttpReply, SseFrame, AcpWsHandshake, AcpWsOutbound,
AcpWsHandler), all in acp-http-core, may change in any minor release. Most applications use the
hosts the SDK ships (the servlet, the listener, Spring MVC’s auto-configuration) and never touch these
types directly. AcpWsOutbound.close now returns CompletionStage<Void>, completing once the close
frame has gone out or the socket has closed, instead of void: a custom host returns its container’s
own close signal (WebFlux’s session.close, Vert.x’s ServerWebSocket.close) or one that completes
when the container reports the socket closed (Jakarta’s Endpoint.onClose).
Quarkus serves ACP on a Vert.x route, without a servlet
Verified against the CHANGELOG and the code at the commit that landed it (ecedfd3).
Breaking: the extension depends on quarkus-vertx-http instead of quarkus-undertow. It mounts
the SDK’s AcpHttpEndpoint on the Quarkus router through a host of its own (AcpVertxHost); the
servlet and the separate WebSocket route it used before (AcpHttpServlet, AcpWebSocketRoute,
VertxWebSocketConnection, and the runtime bean named AcpHttpEndpoint) are gone. Migration: the
endpoint’s path is now under quarkus.http.root-path, no longer under
quarkus.servlet.context-path; an application that relied on acp-quarkus to bring in
quarkus-undertow transitively adds that dependency itself.
Security: HTTP security policies now apply to the WebSocket handshake too. The extension’s
WebSocket route used to run ahead of Quarkus’s own authentication and permission checks (registered
at order Integer.MIN_VALUE), so an unauthenticated client could be upgraded (101) on a path
quarkus.http.auth.permission protected, instead of being refused (401). The endpoint is now one
Vert.x route in the router’s ordinary order, behind those checks, for HTTP and WebSocket alike.
Migration: none to keep working; an application relying on the old, mistaken behavior (a
WebSocket client reaching a protected path without authenticating) now gets 401 instead, correctly.
Spring Boot serves ACP in a reactive (WebFlux) web application too
Verified against the commit (57e0b55 and the two commits immediately before it on the same branch,
10681f7 and 9b48128), the CHANGELOG, and the Spring Boot starter’s own README.
A reactive (WebFlux) web application no longer fails the startup. With
spring.acp.agent.transport.type=http (or websocket) and com.agentclientprotocol:acp-http-webflux
on the classpath, a reactive application now routes spring.acp.agent.transport.http.path to the
endpoint with a RouterFunction bean named acpRouterFunction, on its own server (server.port,
Reactor Netty by default), instead of failing the startup with “The ACP HTTP transport needs a servlet
web application or the standalone listener (acp-streamable-http-jetty); WebFlux is not supported.” The
same spring.acp.agent.transport.* properties apply; there are no new keys. Migration: a reactive
application that serves ACP adds com.agentclientprotocol:acp-http-webflux; without it, the startup
still fails, now naming that module instead of saying WebFlux isn’t supported at all.
As in a servlet application: the endpoint is an AcpHttpEndpoint bean (an application’s own
acpRouterFunction bean replaces the route); a SmartLifecycle in the default phase starts it and
drains it before the server’s graceful shutdown; text/event-stream is taken out of
server.compression’s types; the application’s WebFilters (a SecurityWebFilterChain included)
apply to /acp, the WebSocket handshake included, and the authenticated principal reaches the
endpoint; and the application’s http.server.requests observations cover it. Migration: none.
New module, one more entry in the module rule. A servlet web application adds acp-http-servlet;
a reactive (WebFlux) one adds acp-http-webflux; an application without a web server adds
acp-streamable-http-jetty. The starter itself brings none of them.
Smaller breaking changes
Additive, not breaking, but worth knowing about:
AgentParameters.Builder.inheritEnvironment(boolean): a stdio agent’s process inherits the whole client environment by default (unchanged; now documented as the explicit default rather than an accident of the old implementation), andinheritEnvironment(false)starts it from an empty environment plus only the safe defaults and whateveraddEnvVar(...)adds, for a client that shouldn’t hand every secret in its own environment to an agent subprocess.- Mapper-less transport constructors:
StreamableHttpAcpClientTransport(URI),WebSocketAcpClientTransport(URI),StreamableHttpAcpAgentTransport(int port, AcpAgentFactory), andStreamableHttpAcpServlet(AcpAgentFactory)all default toAcpJsonMapper.createDefault(), as the stdio transports already did.
Fixes worth knowing about, even though nothing to migrate
These don’t require a code change, but they change observed behavior, so code that worked around the old behavior should be revisited.- A handler that threw an
Errorused to leave the peer waiting forever. AnyThrowableescaping a request handler (sync or async, agent or client, annotation-based included) now answers-32603(Internal error) instead, and the connection keeps serving. This matters directly for the 0.18.0-to-0.80.0 upgrade itself: a handler calling a removed 0.18.0 constructor throwsNoSuchMethodErrorat runtime, and before this fix that hung the caller instead of failing fast. AcpAgentSupport.Buildercan now be built more than once. Previously,build()appended custom resolvers/handlers to the builder’s own lists, so a secondbuild()duplicated them, a custom entry added after the firstbuild()call never ran, and agents already built silently shared (and saw later changes to) the builder’s lists.build()now snapshots a composed configuration without mutating the builder, enablingAcpAgentSupport.Builder.buildFactory()(anAcpAgentFactorythat creates a fresh agent per connection from one shared, thread-safe handler bean), a new-feature page held until the architecture brief is approved, but the underlying bug fix applies now to anyone who builds anAcpAgentSupportagent more than once.- A client’s
prompt()now returns only after every earlier notification on that connection has been handled. Before, a response could complete its caller while the session-update consumer was still processing the turn’s last updates, so a caller that read what it had collected right afterprompt()returned could silently miss some. Code that slept, polled, or otherwise waited for “trailing” updates afterprompt()returns can drop that workaround; the guarantee is now built in. A slow consumer delays the response and still counts against the request timeout, so a consumer that sends its own request and waits for it cannot deadlock against a prompt in flight, but must not itself wait for that prompt to complete. - The stdio client’s graceful close lets the agent exit on its own, instead of always killing it.
closeGracefully()used to send SIGTERM immediately, without closing the agent’s standard input, so an SDK stdio agent’s own end-of-input handling never ran and every close ended in exit code 143. It now closes the agent’s standard input first and waits up to two seconds for the agent to exit by itself; only an agent still running after that is sent SIGTERM. Code that checked for exit code 143 as the normal/expected close outcome should check for exit0instead. Closing twice (for examplecloseGracefully()followed by try-with-resourcesclose()) no longer stops the process or logs the stop message a second time. - A response missing a required field now fails the request instead of silently passing nulls
through. A bare
{}used to read as, say, aPromptResponsewith a nullstopReasonor aNewSessionResponsewith a nullsessionId; only inbound params were checked for required fields before. Results are checked the same way now, on both sides and down into nested records: the caller’s request fails withAcpError(-32603), data{"reason": "missing-required-field", "method": <method>, "field": <path>}, the path naming a nested field, such asmodes.currentModeId, not just its containing object. See Errors for this and the other reasons anAcpErrorcan mean the SDK rejected the response itself rather than the peer sending an error. Code that tolerated a peer answering less than the schema requires, intentionally or not, now sees that as a hard failure. - An agent-to-client request reaches its handler only after the session updates sent before it.
Requests used to be dispatched as they arrived, outside the ordered notification drain, so a
session/request_permissioncould reach its handler before the client’s session-update consumers had processed thetool_callupdate the agent sent just before it, leaving the tool call impossible to look up. A request now takes its place in the same drain as a response: dispatched once every notification before it has been handled (handlers still run concurrently with each other and with later updates; a request already being handled when a response comes in isn’t held up behind it, to avoid deadlocking a consumer that’s waiting on its own request). See Concepts: the ordering guarantee, now extended to cover this case too. executereleases its terminal when the prompt is cancelled. ACP requires an agent to release every terminal it creates.executealready released it when the command ended or a step failed, but not when the prompt was cancelled (thesession/cancelgrace period,maxPromptDuration, or$/cancel_request) while waiting interminal/wait_for_exit, leaving the client holding a terminal the agent would never ask about again. It now sendsterminal/releaseexactly once on every path.- At INFO and above, the SDK logs no message content. Three gaps closed: a malformed message that failed validation used to be quoted in full by the exception every transport logs; the WebSocket client used to log its endpoint’s full URI, query string and user information included; and a peer’s error answer used to log its message and data at WARN. All three now log only what identifies the problem (the field at fault; scheme, host, port and path; the error code and the SDK’s description of it), with the peer’s own text, where it’s logged at all, at DEBUG. See Transports: Logging and sensitive data.
- A WebSocket that never sends
initializeis now closed after 30 seconds (1008). Nothing enforced this before; the only thing that eventually caught a silent connection was the (also new) idle timeout. A POSTinitializethe agent hasn’t answered within the same window now gets500too, replacing what used to be a fixed, unconfigurable 30 seconds.
What you may have to change: a quick checklist
- Recompile. This is not a drop-in jar swap.
- Grep for
session/set_model,SetSessionModel,SessionModelState, replacing with a"model"category config option. - Grep for
acp-websocket-jetty,WebSocketAcpAgentTransport, switching toacp-streamable-http-jettyandStreamableHttpAcpAgentTransport. - Grep for
==comparisons againstStopReason,ToolCallStatus,Role,PermissionOptionKind,PlanEntryStatus,PlanEntryPriority, orElicitationActionconstants, switching to.equals(...). - Grep for exhaustive
switchstatements over any of the above, or over a union type (SessionUpdate,ContentBlock,ToolCallContent,SessionConfigOption,PermissionOutcome), adding the newUnknown*branch, or the open-value fallback. - Check any code that sends a second prompt immediately after
session/cancel: it now needs to wait for the cancelled prompt’s answer. - Check any test or display code that asserts an exact status string: it now sees the wire value
(
end_turn), not the enum name (END_TURN). - Grep for
@SessionStateand@AcpExceptionHandler: both are removed (they were non-functional placeholders). Move session state into the agent itself; handle exceptions in the handler or anAcpInterceptor. - Grep for
initialize(new InitializeRequest(: move capabilities to.clientCapabilities(...)on the client builder and callinitialize()instead. - Grep for discriminator-first short constructors of
Plan,AvailableCommandsUpdate,CurrentModeUpdate,UsageUpdate,ConfigOptionUpdate,UserMessageChunk,AgentMessageChunk, andAgentThoughtChunk(e.g.new Plan("plan", ...)): drop the first argument. - If any code constructs
StreamableHttpAcpAgentTransportOptionsvia its canonical constructor (not the builder), add the newshutdownTimeoutcomponent. - If any
SyncPromptContextis implemented directly (a test double, typically), implement the new abstractasync()method. - If you use Spring Boot support, change
org.springaicommunity:acp-spring-boot-starter/acp-spring-boot-autoconfiguretocom.agentclientprotocol, update the version to0.80.0, and fix any import ofcom.agentclientprotocol.autoconfigure.*tocom.agentclientprotocol.sdk.spring.boot.autoconfigure.*. Property names are unchanged. - If any annotated agent has
@SetSessionMode/@SetSessionConfigOptionwithout@NewSession, add one; building now fails without it. - If any custom
ReturnValueHandlercomposition referencesMonoHandlerby name, update it toAsyncValueHandler. - If any custom
PromptContext/SyncPromptContextimplementation exists (typically a test double), implement the newisCancelled()/onCancel(Runnable)/whenCancelled()methods. - If an
@Initializemethod’s response was relied on to suppress a capability a handler would otherwise imply, remove or conditionally register the handler instead: capabilities now merge by OR, so a handler’s presence always advertises. - Grep for
agent.await()(rename toagent.awaitTermination()) andawaitForExit()(rename toawaitProcessExit(), and catchCancellationExceptioninstead ofRuntimeExceptionaround it). - Grep for
context.sendUpdate(sessionId,on aPromptContext/SyncPromptContext; drop the first argument and rename tosendSessionUpdate, so it becomescontext.sendSessionUpdate(update). Leaveagent.sendSessionUpdate(sessionId, ...)alone, it already took the session id and is unaffected. - Grep for
.sessionUpdateConsumer(/.defaultSessionUpdateConsumer(onAcpClient.AsyncSpec/SyncSpec; rename to.sessionUpdateHandler(/.defaultSessionUpdateHandler(. Behavior is unchanged. - Grep for raw
context.readTextFile(/.writeTextFile(/.requestPermission(/.createTerminal(/.getTerminalOutput(/.releaseTerminal(/.waitForTerminalExit(/.killTerminal(/.createElicitation(/.completeElicitation(on aPromptContext/SyncPromptContext; insert.client()before each:context.client().readTextFile(...). Leave the convenience methods (readFile,tryReadFile,writeFile,askPermission,askChoice,execute) and any call onAcpAsyncAgent/AcpSyncAgentdirectly alone, neither moved. - Grep for
.contextWrite(RequestCancellation.cancelWhen(around aclient.prompt(request)call: replace with the two-argumentprompt(request, signal)and callsignal.cancel()instead. See Cancellation. - If a builder handler reads the built agent from an
AtomicReference(asession/loadreplay, or sending aConfigOptionUpdatefromsetSessionConfigOptionHandler), take the agent as the handler’s second parameter instead and drop theAtomicReference. - If using Micronaut, grep for
acp.agent.transport.http.host,.port, or.max-concurrent-streams-per-connection: all three move under...http.listener.*. - If a client registers
readTextFileHandler,writeTextFileHandler, all five terminal handlers, orcreateElicitationHandlerand also setsclientCapabilities(..)purely to advertise that same shape, the explicit call can be dropped; the matching capability is now derived from the handlers automatically. Keep it if the client registers handlers on purpose without advertising them, by passingclientCapabilities(new ClientCapabilities()). - Where it covers the shape needed, prefer the one-flag capability builders
(
ClientCapabilities.builder().readTextFile().writeTextFile(),AgentCapabilities.builder().loadSession()) over the record’s canonical constructor or the builder setters that take a nested record directly; both still work. - Grep for
catch (RuntimeException(orExceptions.unwrap) around a blocking SDK call checking for aTimeoutExceptioncause; replace withcatch (AcpTimeoutException e). - Grep for
AcpAgent.SYNC_HANDLER_SCHEDULER,AcpClient.SYNC_HANDLER_SCHEDULER, andAcpAgent.DEFAULT_REQUEST_TIMEOUT; the first two have no replacement constant (usehandlerExecutor(...)if you need a specific executor), the third becomesDuration.ofSeconds(60). - If any code relied on a long-running prompt being cut off by the client’s request timeout, set
.promptTimeout(...)explicitly; the default is now unbounded. - If any code constructs
CommandResultvia its canonical constructor (not the 3-argument convenience one), add the newtruncatedcomponent. - Grep for
catch (AcpProtocolExceptionaround a client or agent call (not inside a handler, where throwing it is still correct): replace withcatch (AcpError e). - Grep for
client.calls made beforeclient.initialize(): every call butinitializeitself now fails locally withIllegalStateExceptionuntil it has answered. - If a client advertises
fs.readTextFile,fs.writeTextFile,terminal, or an elicitation mode, confirm the matching handler is registered;build()now fails without it. - Grep for a raw
requestHandler/notificationHandlerregistered for a method the SDK models (fs/read_text_file, aterminal/*method,session/request_permission,elicitation/create,session/update,elicitation/complete): switch to the named typed setter. - If a
SyncSpec.notificationHandleris registered, drop theMonofrom its body; it’s now a blockingConsumer<Object>. - If any code relied on the client’s 30-second default request timeout specifically (for example, a
test asserting how long a stub takes to time out), it’s now 60 seconds; set
requestTimeout(...)explicitly if the old number matters. - If a
SessionConfigSelect.Builderis ever built with no options, or acurrentValuenot among them, fix the options beforebuild(); it now throws instead of building. - If a test specifically checked
MockAcpClient’sinitialize()response for aterminalcapability, update it: the mock no longer advertisesterminal. - Grep for
import com.agentclientprotocol.sdk.spring.boot.autoconfigure.client.AcpClientCustomizer(or the equivalent Micronaut/Quarkus package) andTransportType: both now come fromcom.agentclientprotocol.sdk.integration. - If using Spring Boot, grep for
spring.acp.agent.transport.http.portand...max-concurrent-streams-per-connection: both move under...http.listener.*. - If using Spring Boot, set
spring.acp.client.transport.type/spring.acp.agent.transport.typeexplicitly wherever more than one transport property is set; it now fails at startup instead of picking one silently. Quarkus and Micronaut clients need the same check. - If a Spring
ArgumentResolverorReturnValueHandlerwas registered by hand (not as a@Bean), register it as one instead; it’s now picked up automatically, the same asAcpInterceptor. - Grep for
catch (AcpException | AcpError(or any other multi-catch listing both): dropAcpErrorand catchAcpExceptionalone; check the order of any adjacentcatch (AcpError e)/catch (AcpException e)blocks,AcpErrorfirst. - Grep for
catch (RuntimeExceptionorcatch (IllegalStateExceptionaround a call, used to mean “the connection is gone”: replace withcatch (AcpConnectionException e). - Grep for
.toProtocolException()on anAcpCapabilityException: replace withnew AcpProtocolException(AcpErrorCodes.INVALID_PARAMS, e.getMessage(), e.getCapability()). - If any
AcpInterceptor.afterCompletionoverride exists, add theThrowableparameter and@Override; without@Overrideit silently stops being called. - If any handler relied on its own exception’s message reaching the peer (other than through
AcpProtocolException), throwAcpProtocolExceptionwith that message explicitly; the message is no longer sent otherwise. - If a client sends
additionalDirectories, checkgetAgentCapabilities().supportsAdditionalDirectories()first, or catchAcpCapabilityException; if an annotated agent reads them, addadditionalDirectories = trueto@AcpAgent. - If a Micronaut client worked around missing
elicitation-form/elicitation-url/boolean-config-optionsproperties with a customizer override, the properties now exist; the workaround can be dropped. - If a
@Promptmethod (or prompt handler) has atry/catchwhose only job is answeringcancelledaftersession/cancel, it can be dropped; the agent session does this regardless of what the handler returns or throws. - If a builder agent relies on
build()failing without anewSessionHandler, note that it now answerssession/newwith a generated id by default instead. - If any agent code calls
terminal/output,wait_for_exit,kill, orreleaseon a connection where the client might not advertiseterminal, expectAcpCapabilityExceptionnow, not a sent request; these four now check the same ascreatealready did. - If an application mounts
StreamableHttpAcpServletin its own servlet container (Spring MVC included), switch its dependency fromacp-streamable-http-jettytoacp-http-servlet. As of0380b28, using the former on Tomcat fails fast at startup, naming the fix, rather than starting cleanly and failing every WebSocket send withNoSuchMethodErrorlater; on Jetty or Undertow as the servlet container, the startup check doesn’t run at all, so the failure stays silent until a WebSocket send. - If any
StreamableHttpAcpServletsubclass overridesdoGet,doPost, ordoDelete, note thatservicenow handles every request itself; wrap anAcpHttpEndpointinstead to customize behavior. - If a client treats WebSocket close code
1000as meaning “the server is shutting down,” treat1001(going away) the same way instead. - If a Spring MVC application’s own client connected with
transport.websocket.uriand relied on it failing against the servlet-mounted agent, note that it now succeeds: the servlet serves WebSocket too, as of this release. - If a Quarkus application relied on
acp-quarkusto pull inquarkus-undertowtransitively, add that dependency directly. - If a Quarkus application’s path assumed
quarkus.servlet.context-path, usequarkus.http.root-pathinstead. - If a Quarkus application’s own client (or test) connected to
/acpover WebSocket without authenticating and expected it to succeed on a pathquarkus.http.auth.permissionprotects, expect401now instead of a successful upgrade. - If a Spring Boot reactive (WebFlux) application serves ACP, add
com.agentclientprotocol:acp-http-webflux; without it, the startup fails naming it instead of saying WebFlux isn’t supported. - If any test or workaround relied on
spring.acp.agent.transport.type=httpfailing the startup in a WebFlux application with “WebFlux is not supported,” drop it: that failure is gone, replaced by the endpoint actually serving the agent (given the new module above). - If a WebSocket client (or a test) saw close code
1006instead of1001from a Jetty-hosted agent’s graceful shutdown (the standalone listener, or a servlet on Jetty), that was the bug: it reliably gets1001now. - If a custom host of
AcpHttpEndpointimplementsAcpWsOutbound, updatecloseto returnCompletionStage<Void>instead ofvoid, completing once the close frame has gone out or the socket has closed.