# ACP (Agent Client Protocol) Implementation
This document describes the design and implementation of the Agent Client Protocol (ACP) support in ZeptoClaw. It is intended for maintainers and contributors.
**Specification:** [Agent Client Protocol — Overview](https://agentclientprotocol.com/protocol/overview)
---
## 1. Overview and scope
### 1.1 What we implement
ZeptoClaw implements a **minimal** ACP agent surface:
| ACP method / message | Role | Implemented |
|----------------------|------|--------------|
| `initialize` | Handshake; client → agent | Yes — logs client info, returns capabilities; stdio also sets `initialized` flag |
| `session/new` | Create session; client → agent | Yes — requires absolute `cwd`; capped at 1 000 live sessions; stdio enforces prior `initialize` |
| `session/prompt` | Send user prompt; client → agent | Yes — text and ResourceLink blocks; in-flight and size guards; stdio enforces prior `initialize` |
| `session/cancel` | Cancel in-flight prompt; client → agent | Yes — notification; validates session existence |
| `session/update` | Agent progress / result; agent → client | Yes — text chunks for both prompted and proactive replies |
| `session/list` | List live sessions; client → agent | Yes — returns all sessions with cwd/sessionId/title; supports cwd filter and cursor; stdio enforces prior `initialize` |
Two transports are supported:
- **stdio** (`"acp"`) — The client launches ZeptoClaw as a subprocess and communicates over stdin (client → agent) and stdout (agent → client) using newline-delimited JSON-RPC 2.0 messages.
- **Streamable HTTP** (`"acp_http"`) — The client sends `POST /acp` requests over a TCP connection. `session/prompt` responses stream back as Server-Sent Events (`text/event-stream`); all other methods return synchronous JSON. The two transports are independent channels with separate session namespaces.
### 1.2 Out of scope (v1)
- **Client filesystem / terminal callbacks** — No `filesystem/*` or `terminal/*` request handling; the agent does not invoke client-side filesystem or terminal APIs.
- **Streaming token-by-token** — Agent replies are delivered as a single `session/update` with the full text plus a single `session/prompt` response; incremental streaming of tokens is not implemented.
- **MCP over ACP** — MCP server discovery/capabilities are advertised as disabled in `initialize`; no ACP-specific MCP wiring. The `mcpServers` field in `session/new` is accepted but not acted on.
- **Session loading / persistence** — `loadSession` is reported as `false`; the agent does not restore prior session state from the client.
- **Image / audio / embedded-resource content** — `image`, `audio`, and `resource` (embedded) blocks in `session/prompt` are silently ignored. Only `text` and `resource_link` blocks are used.
---
## 2. Design decisions
### 2.1 ACP as a channel
ACP is implemented as a **normal ZeptoClaw channel** (the `Channel` trait). There are two entry points:
1. **`zeptoclaw gateway`** — ACP stdio is **not** registered here; the gateway's stdin is the terminal, not an ACP client. To expose ACP in gateway mode, set `channels.acp.http.enabled = true`, which registers the `acp_http` channel alongside Telegram, Discord, etc. on the shared `MessageBus`.
2. **`zeptoclaw acp`** — Standalone subcommand that starts just the ACP stdio agent without the full gateway. Calls `AcpChannel::run_stdio()`, which blocks until stdin closes. This is the entry point used by `acpx` and other ACP clients that spawn the agent as a subprocess.
**Rationale:**
- Reuses the existing channel lifecycle (start/stop), bus subscription, and outbound dispatch.
- `run_stdio()` blocks for the subprocess model; HTTP `start()` spawns a background task for the gateway model.
- Registering stdio in gateway mode would consume the terminal's stdin and produce parse errors or immediate EOF — never a valid ACP session.
### 2.2 Session and identity mapping
- **Session ID:** ACP’s `sessionId` is generated by the agent in `session/new` (format: `acp_<uuid>`). The same value is used as ZeptoClaw’s **chat ID** for the lifetime of that session, so the agent loop and all routing use one identifier.
- **Sender ID:** All ACP-originated inbound messages use a fixed sender ID `acp_client`. Access control uses the existing channel allowlist/deny logic keyed by this ID (and optional `allow_from` in `AcpChannelConfig`).
This keeps the bus API unchanged: `InboundMessage { channel: "acp", sender_id: "acp_client", chat_id: session_id, content }`.
### 2.3 Prompt–response correlation
`session/prompt` is a **request** (it has a JSON-RPC `id`). The agent’s reply is produced asynchronously by the agent loop and delivered to the channel via `OutboundMessage`. To complete the ACP flow we must:
1. Remember the request `id` when we receive `session/prompt`.
2. When we receive the corresponding `OutboundMessage` for that session, send a `session/update` notification (agent content) and then send the **response** to `session/prompt` with `stopReason: "end_turn"` or `"cancelled"`.
So we maintain a small **pending prompt** state per session: when we publish the inbound message we store `(request_id, cancelled)`; when we handle the outbound message we consume that state, emit `session/update`, then the response.
### 2.4 Cancellation
`session/cancel` is a **notification** (no `id`). We only update state: we mark the pending prompt for that session as cancelled. When the agent eventually produces an outbound message for that session, we still send `session/update` (so the client sees any partial output) and then respond to the original `session/prompt` with `stopReason: "cancelled"`. We do not attempt to kill the in-process agent run; cancellation is cooperative and reflected in the ACP response.
### 2.5 Stdio transport
- **Input:** One JSON-RPC message per line on stdin (newline-delimited). We use a line-based async reader and dispatch by `method`.
- **Output:** One JSON-RPC message per line on stdout; each message is a single line (no embedded newlines). Responses and notifications are flushed after each write so the client can read incrementally.
This matches the ACP stdio transport and keeps parsing simple and robust.
---
## 3. Architecture
### 3.1 Module layout
```text
src/channels/
├── mod.rs # exports acp, acp_http, acp_protocol (private)
├── acp.rs # AcpChannel (stdio, Channel impl), AcpState, stdin loop, send()
├── acp_http.rs # AcpHttpChannel (HTTP, Channel impl), TCP accept loop, SSE streaming
└── acp_protocol.rs # JSON-RPC and ACP method types (request/response/notification)
```
- **acp_protocol.rs** — Pure types: `JsonRpcRequest`, `JsonRpcResponse`, `InitializeParams`/`InitializeResult`, `SessionNewParams`/`SessionNewResult`, `SessionPromptParams`/`SessionPromptResult`, `SessionCancelParams`, `SessionUpdateParams`/`SessionUpdatePayload`, `ContentBlock`, `PromptContentBlock`, etc. No I/O or bus logic.
- **acp.rs** — stdio channel: holds config, `MessageBus`, shared `AcpState`, and locked stdout; runs the stdin loop in a spawned task; implements `Channel::send()` to turn `OutboundMessage` into `session/update` + prompt response.
- **acp_http.rs** — HTTP channel: raw TCP listener; parses HTTP/1.1 requests; handles synchronous methods with plain JSON responses; for `session/prompt` keeps the connection open and streams SSE events. Uses a `PromptMap` (`Arc<Mutex<HashMap<String, oneshot::Sender>>>`) to bridge `send()` to the waiting connection handler.
### 3.2 AcpChannel and AcpState
**AcpChannel** holds:
- `config: AcpChannelConfig` — enabled, allow_from, deny_by_default.
- `base_config: BaseChannelConfig` — channel name and allowlist/deny for `is_allowed()`.
- `bus: Arc<MessageBus>` — for publishing inbound and receiving outbound.
- `running: Arc<AtomicBool>` — so the stdin loop and supervisor can see if the channel is up.
- `state: Arc<Mutex<AcpState>>` — shared mutable state.
- `stdout: Arc<Mutex<tokio::io::Stdout>>` — shared so both the stdin-loop handlers and `send()` can write responses/notifications.
**AcpState** holds:
- `initialized: bool` — set to `true` when the client calls `initialize`; `session/new` and `session/prompt` are rejected until it is true.
- `sessions: HashMap<String, String>` — session ID → `cwd` (always an absolute path, required by spec). Created via `session/new`; capped at `MAX_ACP_SESSIONS` (1 000). The `cwd` is used by `session/list` for filtering.
- `pending: HashMap<String, PendingPrompt>` — per-session pending prompt: `request_id` and `cancelled` flag.
All request handlers and `send()` share the same `AcpState` so that prompt correlation and cancellation are consistent.
### 3.3 Bus integration
- **Inbound:** On `session/prompt` (after validating session and prompt content), we build `InboundMessage::new("acp", "acp_client", session_id, content)`, store the pending prompt, and call `bus.publish_inbound(inbound)`. The existing agent loop consumes from the bus and processes the message.
- **Outbound:** The bus dispatches `OutboundMessage` to each channel’s `send()`. `AcpChannel::send()` ignores messages for other channels; for `channel == "acp"` it looks up `chat_id` as `session_id` in `state.sessions`. If the session is unknown the message is silently dropped. Otherwise it always emits a `session/update` notification with the message content. If a pending prompt exists for that session, it is also completed with a `session/prompt` response (`stopReason: "end_turn"` or `"cancelled"`). If no pending prompt exists (proactive message), only the notification is sent.
No changes were required to the generic bus or agent loop; they only see standard `InboundMessage`/`OutboundMessage` with channel/sender/chat/content.
---
## 4. Protocol behavior (method-by-method)
### 4.1 initialize
- **Handler:** `handle_initialize` (stdio) / `do_initialize` (HTTP).
- **Client info:** If the client sends `clientInfo` (name, version) and `protocolVersion` in the request params, they are parsed and logged at `info!`/`debug!` level for diagnostics. Missing or malformed params are accepted without error.
- **State (stdio only):** Sets `state.initialized = true`. Until this is called on the stdio transport, `session/new`, `session/prompt`, and `session/list` return `-32600` Invalid Request. The HTTP transport does not maintain per-client initialization state (see §6.4).
- **Response fields (schema-authoritative):**
- `protocolVersion: 1` — integer per schema.md (ProtocolVersion type is `uint16`).
- `agentCapabilities.loadSession: false`
- `agentCapabilities.promptCapabilities` — image/audio/embeddedContext all false
- `agentCapabilities.mcpCapabilities` — http/sse both false (field name is `"mcpCapabilities"`, not `"mcp"`)
- `agentCapabilities.sessionCapabilities.list: {}` — advertises `session/list` support
- `agentInfo.name: "zeptoclaw"`, `agentInfo.version: "<crate version>"` — both required strings per schema; `agentInfo.title` is optional
- `authMethods: []`
- **Design:** We do not negotiate capabilities; we advertise a minimal, static set so clients know we support only the methods we implement. `initialize` may be called multiple times; on stdio each call re-sets the flag and re-logs client info.
### 4.2 session/new
- **Params:** `cwd` (required, must be an absolute path per spec), `mcpServers` (accepted but not acted on).
- **Pre-conditions (checked in order):**
1. `is_allowed("acp_client")` — else `-32000` Unauthorized.
2. `cwd` is present and non-empty — else `-32602` Invalid params.
3. `cwd` starts with `/` (is an absolute path) — else `-32602` Invalid params.
4. *(stdio only)* `state.initialized` — else `-32600` Invalid Request.
5. `state.sessions.len()` below `MAX_ACP_SESSIONS` — else `-32000` Too many sessions.
- **Behavior:** Generate `session_id = acp_<uuid>` (HTTP: `acph_<uuid>`), store `(session_id, cwd)` in `state.sessions`, return `{ sessionId }`.
- **Design:** Session is created and tracked in memory only; no filesystem or client callback. Sessions are never explicitly deleted — they persist for the lifetime of the process. The `cwd` is stored so `session/list` can filter by working directory. The cap of 1 000 prevents unbounded memory growth from reconnecting or misbehaving clients.
### 4.3 session/prompt
- **Params:** `sessionId`, `prompt` (array of content blocks).
- **Pre-conditions (checked in order):**
1. `is_allowed("acp_client")` — else `-32000` Unauthorized.
2. *(stdio only)* `state.initialized` — else `-32600` Invalid Request.
3. Content extracted from text and resource_link blocks is non-empty — else `-32602` Invalid params.
4. Content size ≤ `MAX_PROMPT_BYTES` (102 400 bytes) — else `-32602` Invalid params. Enforced before any state change so oversized payloads never reach the bus.
5. `sessionId` is in `state.sessions` — else `-32000` Application error (not `-32602`; unknown session is a runtime condition, not an invalid-params condition).
6. No existing entry in `state.pending` for this session — else `-32602` Invalid params ("a prompt is already in flight"). Clients must wait for the previous `session/prompt` response before sending another.
- **Content extraction:** `text` blocks contribute their text; `resource_link` blocks contribute `[Resource: <name> (<uri>)]`; other types (`image`, `audio`, `resource`, `Other`) are silently ignored. Extracted parts are joined with newlines and trimmed. All agents MUST support `text` and `resource_link` per the ACP spec.
- **Flow:** Store `PendingPrompt { request_id, cancelled: false }` under `session_id`, publish `InboundMessage`, then return without sending a response. The response is sent later from `send()` when the matching `OutboundMessage` arrives.
### 4.4 session/cancel
- **Params:** `sessionId`.
- **Behavior (notification — no `id`):** If the session is not in `state.sessions`, silently ignore (no output — correct per JSON-RPC 2.0 for notifications). If the session exists and has a pending prompt, set `cancelled: true` on that entry. No response is ever written for the notification form.
- **Behavior (request — `id` present):** Perform the same state mutation as the notification path, then write a JSON-RPC result (`{}` acknowledgement) using the same `id` so the caller can correlate. Fabricated or already-closed sessions are treated the same as in the notification path (no state change) but still receive a result response rather than silence, because the caller must receive a reply for every request.
- **Design:** `state.sessions` is the only thing mutated. The `cancelled` flag is read in `send()` when the agent reply arrives; if it is set the prompt response uses `stopReason: "cancelled"` instead of `"end_turn"`. Separating notification from request handling keeps the protocol correct: the spec forbids sending a response to a notification but requires one for a request.
### 4.5 session/list
- **Handler:** `handle_session_list` (stdio) / `do_session_list` (HTTP).
- **Availability:** On the stdio transport, only callable after `initialize` (else `-32600`). The HTTP transport does not enforce ordering. Both transports advertise `sessionCapabilities.list: {}` in the `initialize` response.
- **Params:** `cwd` (optional string — filter sessions by working directory); `cursor` (optional — accepted but pagination is not yet implemented; `nextCursor` is always null).
- **Behavior:** Iterate `state.sessions`. If `cwd` is provided, return only sessions whose stored cwd matches exactly. Each session is returned as a `SessionInfo` object:
- `sessionId: String` (required)
- `cwd: String` (required — the absolute path stored at `session/new` time)
- `title: null` (not implemented)
- `updatedAt: null` (not implemented)
- `_meta: { "pending": bool }` — non-standard extension indicating whether a prompt is in flight
- **Response:** `{ sessions: [...], nextCursor: null }`.
- **Design:** `_meta.pending` lets clients show active-session indicators without a separate status call.
### 4.6 session/update (agent → client)
- **Emitted in:** `AcpChannel::send()`.
- **Payload:** `sessionUpdate: "agent_message_chunk"`, `content: { type: "text", text: "<message content>" }`. One notification is sent per outbound message (full text of that message).
- **Prompted path:** After the notification, a `session/prompt` response is sent with `stopReason: "end_turn"` or `"cancelled"`.
- **Proactive path:** If the agent sends an outbound message for a known session but no pending prompt is in flight (e.g. from a cron routine or a spawned task), only the `session/update` notification is sent; no `session/prompt` response is sent (there is no open request to complete).
- **Unknown session:** If the session is not in `state.sessions`, the outbound message is silently dropped.
### 4.7 Errors
| Code | Meaning | Triggers |
|------|---------|---------|
| `-32700` | Parse error | Invalid JSON on stdin |
| `-32600` | Invalid Request | `jsonrpc` field not `"2.0"`; on stdio: `session/new`, `session/prompt`, or `session/list` before `initialize` |
| `-32601` | Method not found | Unrecognized method name |
| `-32602` | Invalid params | Missing/malformed params; empty prompt; prompt too large; prompt already in flight |
| `-32603` | Internal error | Unexpected handler failure |
| `-32000` | Server error | Unauthorized (`is_allowed` failed); too many sessions; unknown session in `session/prompt` |
**Note on `-32602` vs `-32000` for unknown sessions:** An unknown `sessionId` in `session/prompt` is a runtime condition (the session was never created, or the connection was lost), not a malformed parameter. We use `-32000` for this case so clients can distinguish "you gave me bad JSON" (−32602) from "I don't know that session" (−32000) and respond accordingly (e.g. prompt the user to create a new session vs showing a validation error).
All error responses include the request `id` when available so the client can correlate.
---
## 5. Configuration
Config lives under `channels.acp` (`AcpChannelConfig`):
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | false | **No-op in gateway mode.** Accepted for backward compatibility; `zeptoclaw acp` always starts ACP stdio regardless of this flag. |
| `allow_from` | list of string | [] | If non-empty, only these sender IDs are allowed. |
| `deny_by_default` | bool | false | If true, empty `allow_from` rejects all senders. |
| `http` | `AcpHttpConfig` or null | null | HTTP transport config (see below). `channels.acp.enabled` is not required for HTTP. |
### HTTP transport (`channels.acp.http`)
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | false | If true, register the ACP HTTP listener in gateway mode. `channels.acp.enabled` is not required. |
| `port` | u16 | 8765 | TCP port to listen on. |
| `bind` | string | `"127.0.0.1"` | Bind address. Use `"0.0.0.0"` to expose on all interfaces. |
| `auth_token` | string or null | null | When set, all requests must carry `Authorization: Bearer <token>`. |
**Example config (HTTP in gateway mode):**
```json
{
"channels": {
"acp": {
"http": {
"enabled": true,
"port": 8765,
"bind": "127.0.0.1",
"auth_token": "my-secret-token"
}
}
}
}
```
`channels.acp.enabled` is not required for HTTP. For `zeptoclaw acp` (stdio), no config flag is needed — just run the subcommand. `allow_from` and `deny_by_default` apply to both transports.
### Environment variable overrides (`channels.acp.http`)
All HTTP transport fields can be set via environment variables (override config file values):
| Variable | Config field |
|----------|-------------|
| `ZEPTOCLAW_CHANNELS_ACP_HTTP_ENABLED` | `channels.acp.http.enabled` |
| `ZEPTOCLAW_CHANNELS_ACP_HTTP_PORT` | `channels.acp.http.port` |
| `ZEPTOCLAW_CHANNELS_ACP_HTTP_BIND` | `channels.acp.http.bind` |
| `ZEPTOCLAW_CHANNELS_ACP_HTTP_AUTH_TOKEN` | `channels.acp.http.auth_token` |
Allowlist/deny is applied via `BaseChannelConfig` built from `allow_from` and `deny_by_default`. Both the stdio and HTTP transports inherit the same allowlist. In the typical single-client setup, `allow_from` is empty and `deny_by_default` is false.
---
## 6. Implementation details
### 6.1 Stdin loop
- **Entry points:** Two ways to run the loop:
- `Channel::start()` — spawns the loop as a Tokio background task and returns immediately. Required by the `Channel` trait; available but not called in gateway mode (ACP stdio is never registered there).
- `AcpChannel::run_stdio()` — runs the loop directly on the caller's task and blocks until stdin closes. This is the only production entry point, used by `zeptoclaw acp` so the standalone subprocess stays alive for the full client session.
- The spawned task (from `start()`) holds `Arc::clone(&self.running)` — the same atomic as the `AcpChannel` struct — so `is_running()` and `stop()` observe the same state as the task.
- Uses `BufReader::new(stdin).lines()` and `next_line()` in a loop while `running` is true. The loop exits on stdin EOF, read error, or `stop()` setting the flag.
- Blank lines are skipped. Each non-blank line is parsed as JSON into `JsonRpcRequest`; then we dispatch by `method` and call the appropriate handler. Handlers that need channel/state/stdout receive an `AcpChannel` value built via `channel_ref()` so they can call the same methods as the main struct.
- Errors returned by handlers are logged and a `-32603` Internal error response is sent to stdout with the original request `id`.
- Panics inside the task bubble up as a Tokio `JoinError` (logged by Tokio's unhandled-panic handler). No `catch_unwind` is used; Tokio task isolation is sufficient.
- **Graceful shutdown:** After the loop exits, the task drains `state.pending` and sends a `session/prompt` response with `stopReason: "error"` for every in-flight prompt, so clients are not left waiting indefinitely. Only then does the task set `running = false`.
### 6.2 Pending prompt lifecycle
- **Guard:** Before inserting, `handle_session_prompt` checks that `state.pending` does not already contain an entry for this session. If one exists, the new request is rejected with `-32602`; the in-flight prompt is not affected. This enforces one outstanding prompt per session at a time.
- **Insert:** After all validation passes, we insert `PendingPrompt { request_id, cancelled: false }` into `state.pending` and then publish to the bus. We do not respond yet.
- **Remove and respond:** In `send()`, when we see an `OutboundMessage` for this channel and session, we atomically read `session_exists` and `remove` the pending prompt in a single lock. We then send `session/update`; if a pending prompt was present, we complete it with the appropriate `session/prompt` response. Each prompt is completed exactly once.
- **On shutdown:** Any entries remaining in `state.pending` when the stdin loop exits are drained and responded to with `stopReason: “error”` (see §6.1).
### 6.3 Stdout locking
- Both the stdin-loop handlers and `send()` write to stdout. All writes go through `write_response()` or `write_notification()`, which take `&self` and lock `self.stdout`. So every response/notification is atomic with respect to other writes; we never interleave two messages on one line.
### 6.4 initialize-first ordering
- The ACP spec requires the client to call `initialize` before any session methods. The **stdio transport** enforces this: `session/new`, `session/prompt`, and `session/list` all check `state.initialized` and return `-32600` Invalid Request if it is false. `initialize` itself is always permitted.
- The **HTTP transport** does not maintain per-client initialization state. `client_id` is not part of the ACP spec, and tracking it server-side over stateless HTTP connections would require a non-spec mechanism with its own DoS surface (unbounded client set). Clients are expected to follow the protocol; the HTTP transport trusts them to do so.
---
## 7. Known limitations
- **One pending prompt per session:** Only one `session/prompt` may be in flight per session at a time. A second prompt while one is in flight is rejected with `-32602`. Clients must wait for the `session/prompt` response before sending the next prompt on the same session (or use separate sessions for concurrent conversations).
- **No session deletion:** Sessions live until the gateway process exits. There is no `session/delete` or equivalent; the cap of 1 000 sessions is the only bound on memory growth.
- **No streaming:** The full agent reply is sent in one `session/update`. Streaming would require either multiple `session/update` notifications or a different update type and client support.
- **HTTP transport: no persistent connection between prompts:** The HTTP channel holds the TCP connection open only for the duration of a single `session/prompt`; proactive messages (from cron, spawned tasks) cannot be delivered to the client because there is no long-lived SSE subscription separate from the request.
- **Prompt content:** `text` and `resource_link` blocks are used (per spec, both MUST be supported). `image`, `audio`, and embedded `resource` blocks are silently ignored; the agent never receives them.
- **No loadSession:** We always report `loadSession: false`; session persistence would require additional protocol and storage design.
---
## 8. Future work
The following are candidate improvements and extensions, not commitments.
### 8.1 Transport
- **Persistent SSE subscription:** Add a `GET /acp/events?session=<id>` endpoint so the HTTP client can maintain a long-lived event stream and receive proactive agent messages (from cron, spawned tasks) between `session/prompt` turns.
- **WebSocket transport:** Optional alternative to raw SSE for clients that prefer full-duplex framing.
- **Centralized gateway HTTP server:** Currently each HTTP-capable channel (webhook, WhatsApp Cloud, ACP HTTP) owns its own `TcpListener` on a dedicated port. A future `gateway-http` feature could introduce a shared axum server on `gateway.host:gateway.port` and a companion `HttpChannel` trait (`mount_path()` + `http_router() -> axum::Router`). Channels that implement `HttpChannel` would contribute routes to the shared server instead of binding their own port — matching the architecture OpenClaw uses. This simplifies firewall/proxy configuration (one port to expose) and enables shared middleware (rate limiting, CORS, auth). The `gateway-http` feature would be implied by the existing `panel` feature so that the axum dependency remains opt-in.
### 8.2 Protocol and capabilities
- **Streaming replies:** Emit multiple `session/update` notifications as the agent produces tokens or segments, instead of a single update at end of turn. Requires agent-loop support for streaming callbacks and a clear contract for chunk boundaries.
- **Rich prompt content:** Support `image` and `resource` content blocks in `session/prompt` (e.g. pass URLs or inline data to the agent, or integrate with client filesystem if we add those callbacks).
- **Tool call and tool result updates:** Send `session/update` messages that represent tool invocations and results (e.g. `tool_use` / `tool_result`-style blocks) so the client can show structured progress.
- **loadSession:** If the spec stabilizes session loading, implement optional persistence of session state (e.g. in `~/.zeptoclaw`) and advertise `loadSession: true` when enabled; handle client requests to restore a session.
### 8.3 Client callbacks (optional)
- **Filesystem and terminal:** If ACP standardizes client-side filesystem/terminal requests, add handlers that forward to the client via JSON-RPC and integrate responses into the agent context. Requires security policy (allowlists, sandboxing) and clear UX for user approval.
- **MCP over ACP:** If the client advertises MCP servers in `session/new` or a dedicated method, consider wiring those into ZeptoClaw’s MCP client or exposing them to the agent (with appropriate isolation and policy).
### 8.4 Robustness and scale
- **Multiple concurrent pending prompts per session:** Queue pending prompts per session (e.g. by request `id`) and complete each with the corresponding agent reply. Requires a clear matching strategy when the agent can produce multiple turns or when cancellation applies to a specific request.
- **Request timeouts:** Enforce a timeout for `session/prompt`; if the agent does not respond in time, send a JSON-RPC error or `stopReason: "error"` and clear the pending state so the client can retry or create a new session.
- **Session expiry:** Remove sessions from `state.sessions` after a period of inactivity or after a configurable maximum age, to reclaim memory in long-running gateway deployments without a process restart.
### 8.5 Testing and observability
- **End-to-end tests:** ✅ Done — `tests/acp_acpx.rs` provides 23 integration tests covering wire protocol and `acpx`-driven flows (see §9).
- **Structured logging:** Add trace or debug logs for ACP method invocations (method, session id, request id) and outbound delivery, with optional redaction of prompt content for privacy.
- **Metrics:** Count ACP methods, prompt latency (time from `session/prompt` to response), and cancellation rate for monitoring and tuning.
---
## 9. Testing
### 9.1 Unit tests — `src/channels/acp.rs` (15 tests)
| Test | What it verifies |
|------|-----------------|
| `test_acp_prompt_blocks_to_text` | Text blocks joined with newlines |
| `test_acp_prompt_blocks_to_text_skips_non_text` | Non-text blocks (`Other`) are ignored |
| `test_send_ignores_wrong_channel` | `send()` with `channel != "acp"` does not consume the pending prompt |
| `test_deny_by_default_blocks_session_new` | `deny_by_default: true` prevents session creation |
| `test_prompt_size_limit_does_not_insert_pending` | Oversized prompt does not mutate `state.pending` |
| `test_in_flight_prompt_guard_preserves_first_request_id` | Second in-flight prompt does not overwrite first `request_id` |
| `test_initialize_sets_initialized_flag` | `handle_initialize` sets `state.initialized = true` |
| `test_session_new_requires_initialize` | `session/new` before `initialize` creates no session |
| `test_session_prompt_requires_initialize` | `session/prompt` before `initialize` creates no pending entry |
| `test_session_cancel_unknown_does_not_affect_known_pending` | Cancel of unknown session leaves other sessions' pending intact |
| `test_send_skips_unknown_session` | `send()` for unknown session does not create the session |
| `test_send_proactive_known_session_does_not_remove_session` | Proactive `send()` keeps the session in `state.sessions` |
| `test_session_cap_does_not_insert_beyond_limit` | Session cap prevents insertion beyond `MAX_ACP_SESSIONS` |
| `test_session_list_requires_initialize` | `session/list` before `initialize` returns `-32600` |
| `test_session_list_returns_all_sessions` | `session/list` returns all created sessions with correct `sessionId` and `cwd` |
### 9.2 Factory tests — `src/channels/factory.rs`
- `test_register_configured_channels_acp_enabled_alone_registers_nothing` — `channels.acp.enabled: true` alone registers zero channels (no `"acp"` stdio channel in gateway mode).
- `test_register_configured_channels_registers_acp_http` — `channels.acp.http.enabled: true` registers exactly one `"acp_http"` channel (count = 1); `channels.acp.enabled` is not required.
### 9.3 HTTP channel unit tests — `src/channels/acp_http.rs`
| Test | What it covers |
|------|---------------|
| `test_channel_name` | `name()` returns `"acp_http"` |
| `test_is_not_running_initially` | `is_running()` is false before `start()` |
| `test_prompt_blocks_to_text_*` | Text extraction (shared logic with stdio channel) |
| `test_send_ignores_wrong_channel` | Wrong-channel `send()` does not consume the pending entry |
| `test_send_skips_unknown_session` | `send()` for an unknown session is a no-op |
| `test_send_delivers_via_oneshot` | `send()` delivers content + cancelled=false through the oneshot channel |
| `test_send_marks_cancelled` | `send()` propagates cancelled=true from `state.pending` |
| `test_deny_by_default_blocks_session_new` | `deny_by_default: true` rejects `session/new` |
| `test_constant_time_eq_*` | Auth token comparison helpers (length-constant XOR path) |
| `test_sse_event_format` | SSE event `data:` line formatting |
| `test_http_200_content_length` | `Content-Length` header matches body length |
| `test_initialize_returns_spec_fields` | `initialize` response includes spec-required fields and no `clientId` |
| `test_initialize_to_session_new_round_trip` | `initialize` → `session/new` produces a valid `sessionId` without a client token |
| `test_initialize_to_session_new_to_session_list_round_trip` | Full three-step round-trip; `session/list` returns the created session |
| `test_session_list_no_sessions` / `test_session_list_empty` | `session/list` returns `[]` before and after `initialize` with no sessions |
| `test_session_list_shows_sessions_with_pending_flag` | `_meta.pending` flag reflects in-flight prompt state |
| `test_session_new_rejects_missing_cwd` | `session/new` without `cwd` returns `-32602` and creates no session |
| `test_session_new_rejects_relative_cwd` | `session/new` with a relative path returns `-32602` and creates no session |
| `test_session_new_stores_absolute_cwd` | `session/new` with an absolute path succeeds and stores `cwd` verbatim |
| `test_register_prompt_blocked_without_client_id` | `register_prompt` rejects an unknown `sessionId` |
| `test_session_cancel_*` | Notification and request forms of `session/cancel`; state and response differences |
### 9.4 Integration tests — `tests/acp_acpx.rs` (23 tests)
These tests spawn a real `zeptoclaw acp` subprocess and communicate over stdin/stdout using raw JSON-RPC. No LLM calls are made by the wire-protocol tests (they validate protocol-level behavior before prompts reach the agent loop). The four `acpx` end-to-end tests require a live API key and are gated by `ZEPTOCLAW_E2E_LIVE=1`.
Run with concurrency limiting to avoid config file contention across subprocess spawns:
```bash
cargo nextest run --test acp_acpx
```
Nextest is configured in `.config/nextest.toml` to set `threads-required = 4` for all tests in this binary, which limits the number of concurrent `zeptoclaw acp` subprocesses without affecting parallelism for other test binaries.
**Wire-protocol tests (no LLM, 19 tests):**
| Test | What it verifies |
|------|-----------------|
| `test_initialize_protocol_version_is_integer_one` | `initialize` result has `protocolVersion: 1` (integer, not string) |
| `test_initialize_advertises_session_list_capability` | `agentCapabilities.sessionCapabilities.list` is present in response |
| `test_initialize_agent_info_fields_are_strings` | `agentInfo.name` and `agentInfo.version` are non-empty strings |
| `test_initialize_mcp_capabilities_field_name` | `agentCapabilities.mcpCapabilities` is the correct wire field name (not `"mcp"`) |
| `test_initialize_auth_methods_defaults_to_empty_array` | `authMethods` is `[]` when not configured |
| `test_session_new_before_initialize_returns_error` | `session/new` before `initialize` returns `-32600` |
| `test_session_prompt_before_initialize_returns_error` | `session/prompt` before `initialize` returns `-32600` |
| `test_unknown_method_returns_method_not_found` | Unrecognized method returns `-32601` |
| `test_malformed_json_returns_parse_error` | Invalid JSON on stdin returns `-32700` |
| `test_session_new_returns_session_id` | `session/new` returns a non-empty `sessionId` |
| `test_session_new_returns_unique_ids` | Two `session/new` calls return distinct session IDs |
| `test_session_list_contains_created_sessions` | `session/list` includes all previously created sessions |
| `test_session_list_cwd_filter` | `session/list` with `cwd` param returns only matching sessions |
| `test_session_list_session_info_has_cwd` | Each `SessionInfo` in the list has the `cwd` stored at creation time |
| `test_session_list_before_initialize_returns_error` | `session/list` before `initialize` returns `-32600` |
| `test_session_prompt_unknown_session_returns_error` | `session/prompt` with unknown sessionId returns `-32000` (not `-32602`) |
| `test_session_cancel_sends_no_response` | `session/cancel` (notification) produces no JSON-RPC response |
| `test_double_initialize_is_idempotent` | Calling `initialize` twice does not error; second response has valid `protocolVersion` |
| `test_session_prompt_accepts_resource_link_content` | `resource_link` content block in `session/prompt` is accepted (does not error with `-32602`) |
**`acpx` end-to-end tests (require `ZEPTOCLAW_E2E_LIVE=1`, 4 tests):**
| Test | What it verifies |
|------|-----------------|
| `test_acpx_exec_basic_prompt` | `acpx exec` produces a non-empty response for a simple prompt |
| `test_acpx_exec_produces_session_update_events` | `acpx exec` output includes at least one `session/update` notification |
| `test_acpx_exec_ends_with_end_turn` | Final `session/prompt` response has `stopReason: "end_turn"` |
| `test_acpx_sessions_list_after_exec` | `acpx sessions list` shows the session created by a preceding `exec` |
---
## 10. References
- [Agent Client Protocol — Overview](https://agentclientprotocol.com/protocol/overview)
- [JSON-RPC 2.0 Specification](https://www.jsonrpc.org/specification)
- ZeptoClaw channel layer: `src/channels/mod.rs`, `src/channels/factory.rs`
- ZeptoClaw bus: `src/bus/mod.rs`, `src/bus/message.rs`