ACP host
August 2, 2026 · View on GitHub
Status: implemented, host/client side. This documents what actually landed and where it differs from the design in the sibling docs. Source of truth for class names and file locations.
File map
ACP code lives in LLMQore::Acp; the shared JSON-RPC plumbing lives in LLMQore::Rpc.
Layering (as built)
A self-contained LLMQore::Rpc layer carries the shared JSON-RPC plumbing; the MCP and
ACP stacks both build on it.
Rpc::Transport(abstract),Rpc::StdioClientTransport,Rpc::PipeTransport,Rpc::LineFramer— byte-level transports + framing.Rpc::JsonRpcSession— request/response/notification dispatch, timeouts, progress, cancellation.Rpc::ErrorCode+ the exception hierarchyRpc::JsonRpcException,RemoteError,TransportError,TimeoutError,CancelledError,ProtocolError.
Both stacks reference the Rpc:: names directly — the historical Mcp::McpTransport
/ Mcp::McpSession / Mcp::McpStdioClientTransport spellings were removed in the #27
refactor with no compatibility aliases. The renames were:
| Removed MCP name | replaced by |
|---|---|
Mcp::McpTransport | Rpc::Transport |
Mcp::McpSession | Rpc::JsonRpcSession |
Mcp::McpStdioClientTransport / StdioLaunchConfig | Rpc::StdioClientTransport / Rpc::StdioLaunchConfig |
Mcp::McpPipeTransport | Rpc::PipeTransport |
Mcp::McpRemoteError / McpException / … | Rpc::RemoteError / Rpc::JsonRpcException / … |
Mcp::ErrorCode | Rpc::ErrorCode |
MCP-specific transports (McpStdioServerTransport, McpHttpTransport,
McpHttpServerTransport) stay in Mcp and inherit Rpc::Transport.
Two design choices to note:
- Progress / cancellation live in
Rpc::JsonRpcSession. They are generic JSON-RPC patterns (MCP standardised thenotifications/progress/notifications/cancelledmethod names) that an ACP agent simply never triggers. - Permission has no capability flag.
session/request_permissionis always handled; with noAcpPermissionProviderthe host answersoutcome: cancelled. Onlyfs.*andterminalare gated by provider presence ininitialize.
Method coverage
| ACP method | Direction | Status |
|---|---|---|
initialize | host → agent | ✓ AcpClient::connectAndInitialize |
authenticate | host → agent | ✓ AcpClient::authenticate |
session/new | host → agent | ✓ AcpClient::newSession |
session/load | host → agent | ✓ AcpClient::loadSession |
session/prompt | host → agent | ✓ AcpClient::prompt |
session/cancel | host → agent (notify) | ✓ AcpClient::cancel |
session/set_mode | host → agent | ✓ AcpClient::setMode |
session/update | agent → host (notify) | ✓ routed to typed signals |
session/request_permission | agent → host | ✓ AcpPermissionProvider |
fs/read_text_file | agent → host | ✓ AcpFileSystemProvider |
fs/write_text_file | agent → host | ✓ AcpFileSystemProvider |
terminal/create | agent → host | ✓ AcpTerminalProvider |
terminal/output | agent → host | ✓ AcpTerminalProvider |
terminal/wait_for_exit | agent → host | ✓ AcpTerminalProvider |
terminal/kill | agent → host | ✓ AcpTerminalProvider |
terminal/release | agent → host | ✓ AcpTerminalProvider |
session/update variants handled: user_message_chunk, agent_message_chunk,
agent_thought_chunk, tool_call, tool_call_update (merged by toolCallId),
plan, available_commands_update, current_mode_update, usage_update (raw JSON via
the AcpClient::usageUpdated signal).
Live validation
Validated end-to-end against Claude Code via the @agentclientprotocol/claude-agent-acp
adapter (the renamed @zed-industries/claude-code-acp):
initialize → session/new → session/prompt → streamed agent_message_chunk →
stopReason: "end_turn", driven by an AcpClient host (the example-chat ACP path).
This is now automated: tests/integration/tst_AcpIntegration.cpp
launches the adapter with npx and runs six conformance cases against it. Build with
-DLLMQORE_BUILD_INTEGRATION_TESTS=ON and run
LLMQoreIntegrationTests --gtest_filter='AcpIntegrationTest.*' (~20 s, real tokens).
Corrections that came out of these runs and are folded in:
stopReasonvalues areend_turn/max_tokens/max_turn_requests/refusal/cancelled— not theCompleted/Erroran earlier schema summary implied.StopReason::*and the loopback tests now use these.usage_updateis a realsession/updatevariant (token accounting), surfaced via theAcpClient::usageUpdated(sessionId, rawJson)signal.- Auth is the agent's job. Claude's adapter advertises no
authMethodsand reads its credential from the environment (CLAUDE_CODE_OAUTH_TOKEN/ANTHROPIC_API_KEY) or the macOS Keychain. A GUI-launched host must pass a token explicitly — seeauthentication.md.
Unreachable surface (this agent will never exercise it)
Measured against @agentclientprotocol/claude-agent-acp 0.64.0 (ACP SDK 1.2.1) over a
full tool-using turn. The adapter called exactly one host method the whole turn:
session/request_permission.
fs/read_text_file/fs/write_text_file— never called. The Claude Agent SDK's Read and Write tools go to the filesystem directly. AdvertisingclientCapabilities.fs.readTextFile/.writeTextFiledoes not change this: in the adapter these two methods are pass-through forwarders required by the ACPAgentinterface that nothing on the tool path invokes, and no flag or setting reroutes them.terminal/*— never called. The Bash tool runs in-process. The adapter's onlyterminalcapability reference isclientCapabilities.auth.terminal, an auth-flow concern unrelated to ourterminalflag.
Both stay covered by tst_AcpLoopback alone, and now actually are:
AcpLoopbackTest.PromptDrivesHostCallbacksFsAndPermission drives fs/*, and
AcpLoopbackTest.TerminalCallsReachTheProviderAndComeBackInShape drives all five
terminal/* methods plus the refusal a host without a terminal provider sends back.
This is a property of the agent, not a gap in effort: no amount of work against this adapter will close it, and a conformance test
asserting the file appeared on disk would be a false green — the agent writes it without
touching the host provider. A different agent could close it; swap one in with
LLMQORE_ACP_AGENT_CMD / LLMQORE_ACP_AGENT_ARGS.
Validated live
session/request_permissionoutcome shape — confirmed. The agent offersallow_always/allow_once/reject_once(optionIdsallow_always/allow/reject), and accepts our{ "outcome": { "outcome": "selected", "optionId": … } }. The permissiontoolCallcarriesrawInput,title,kind,content(adiffblock whoseoldTextmay benull) andlocations— but nostatus.protocolVersion— the SDK'sPROTOCOL_VERSIONis1, matchingkAcpProtocolVersion.- Partial
tool_call_updatemerging — the agent sends many updates carrying onlytoolCallIdplus one or two fields;mergeToolCallpreserving priortitle/rawInputacross them is exercised for real.
Known gaps in our types
NewSessionResult.configOptionsis dropped — the agent sends it next tomodes.config_option_updateis not inSessionUpdateKind, so it falls through to the unknown-kind debug log. This is whatsession/set_modeactually triggers: the agent answers{}and emitsconfig_option_update, notcurrent_mode_update, soAcpClient::modeChangednever fires on an explicit mode switch.ClientCapabilitiesserialises only{fs, terminal}, while the agent readselicitation.form/elicitation.url/session.configOptions.boolean.
Still unvalidated
- Agent catalogue — agents are data, loaded by
AcpAgentRegistryfrom external JSON (example/agents.json); the library ships no built-in agents. Theclaudeentry is live-verified; thecodexpackage name is best-effort. Thegeminientry was dropped — Google discontinued the project, so its--experimental-acpinvocation is dead weight in an example catalogue.
Minimal usage
using namespace LLMQore;
using namespace LLMQore::Acp;
AcpAgentRegistry registry;
registry.loadFromFile("agents.json"); // or ":/agents.json"
auto cfg = registry.config("claude", QDir::currentPath()).value();
auto *client = new AcpClient(cfg.createTransport(parent), {}, parent);
client->setFileSystemProvider(new DefaultFileSystemProvider(parent));
client->setTerminalProvider(new TerminalManager(parent));
client->setPermissionProvider(new CallbackPermissionProvider(myPolicy, parent));
QObject::connect(client, &AcpClient::agentMessageChunk,
[](const QString &sid, const ContentBlock &c){ /* render c.text */ });
compat(client->connectAndInitialize())
.then(client, [client](const InitializeResult &){ return client->newSession({/*cwd*/}); })
.unwrap()
.then(client, [client](const NewSessionResult &ns){
return client->prompt(ns.sessionId, {ContentBlock::makeText("Hello")}); })
.unwrap();
See the ACP path in example/ChatController.cpp
(setupAcpAgent / send) for a GUI host — pick the Claude Code (ACP) provider in
example-chat.
Tests
tst_AcpTypes (round-trip of every wire struct), tst_AcpLoopback (handshake,
streaming, cancel, and a full prompt turn where the agent calls back into the host for
fs/read_text_file + session/request_permission), tst_AcpProviders
(DefaultFileSystemProvider, TerminalManager, CallbackPermissionProvider),
tst_AcpAgentConfig (launch config + JSON round-trip), tst_AcpAgentRegistry
(catalogue load/merge/lookup). All run under the standard
LLMQoreUnitTests target with no network or external processes (the terminal test runs
echo).
tst_AcpIntegration (conformance against a live adapter: initialize, session/new,
session/set_mode, a smoke turn, a cancelled turn, and a permission-bearing tool turn)
runs under LLMQoreIntegrationTests, gated by LLMQORE_BUILD_INTEGRATION_TESTS. It
launches npx -y @agentclientprotocol/claude-agent-acp unless overridden by
LLMQORE_ACP_AGENT_CMD / LLMQORE_ACP_AGENT_ARGS, and gives the agent a QTemporaryDir
sandbox — the host refuses any callback naming a path outside it. If the agent cannot be
launched the cases skip; if the agent answers with errorKind: "authentication_failed"
they fail, pointing at authentication.md.
Running from a GUI launcher. macOS gives Finder-launched apps a bare
/usr/bin:/bin:/usr/sbin:/sbin, so a Qt Creator run finds neither npx nor the node
its #!/usr/bin/env node shebang needs. LLMQORE_ENV_PATH covers both: its entries are
prepended to the search path used to locate the agent binary and to the PATH handed to
the launched process. It is a general integration-test knob (childSearchPath() /
childEnvironment() in IntegrationTestHelpers.hpp), not ACP-specific — nothing about a
machine's layout is hardcoded anywhere. Without it the cases skip and print the search path
they used. CLAUDE_CODE_OAUTH_TOKEN has to be set the same way, since no shell profile is
sourced for a Finder-launched process.
LLMQORE_ENV_PATH=/opt/homebrew/bin
CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat…