Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
interrupt hint, and a minimum-width slice for the verb all outrank the row's idle
elements (mode hint, token estimate, queue badges) for space, which are what gets
dropped first under width pressure.
- `zeph-acp`: migrated `agent-client-protocol` `1.2.0` → `2.0.0`, schema `=1.4.0` → `=1.5.0`
(issue #6655). Mechanical crate-major bump — no wire protocol change (`ProtocolVersion::LATEST`
stays `V1` == `1` under the pinned schema; a compile-time regression guard in
`crates/zeph-acp/src/lib.rs` catches a *future* schema-pin bump that redefines `LATEST`, plus an
independent runtime test hardcodes the literal `1` so the invariant can't be silently weakened);
no handler, transport, or builder-chain logic changed. See `specs/013-acp/spec.md` v1.11–v1.12
for the full breaking-change resolution table and live-test evidence.
- `zeph-tui`: unified `Ctrl+C` semantics in the TUI (issue #6646). `Ctrl+C` now cancels the
current agent turn immediately when the agent is busy (moved from `Esc`, which no longer
cancels anything in Normal mode). When idle, a single `Ctrl+C` no longer quits outright —
Expand Down
27 changes: 20 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ publish = true

[workspace.dependencies]
age = { version = "0.12.1", default-features = false }
agent-client-protocol = "1.2.0"
agent-client-protocol-schema = "=1.4.0"
agent-client-protocol = "2.0.0"
agent-client-protocol-schema = "=1.5.0"
anyhow = "1.0.103"
arboard = "3.6.1"
arc-swap = "1.9.2"
Expand Down
2 changes: 1 addition & 1 deletion crates/zeph-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ ACP (Agent Client Protocol) server adapter for embedding Zeph in IDE environment

## Overview

Implements the [Agent Client Protocol](https://agentclientprotocol.org) server side, allowing IDEs and editors to drive the Zeph agent loop over stdio, HTTP+SSE, or WebSocket transports. The crate wires IDE-proxied capabilities — file system access, terminal execution, and permission gates — into the agent loop via `AcpContext`, exposes `AgentSpawner` as the integration point for the host application, and supports runtime model switching via `ProviderFactory` and MCP server management via `ext_method`. Built on the `agent-client-protocol` SDK v1.0.
Implements the [Agent Client Protocol](https://agentclientprotocol.org) server side, allowing IDEs and editors to drive the Zeph agent loop over stdio, HTTP+SSE, or WebSocket transports. The crate wires IDE-proxied capabilities — file system access, terminal execution, and permission gates — into the agent loop via `AcpContext`, exposes `AgentSpawner` as the integration point for the host application, and supports runtime model switching via `ProviderFactory` and MCP server management via `ext_method`. Built on the `agent-client-protocol` SDK v2.0.

## Installation

Expand Down
28 changes: 28 additions & 0 deletions crates/zeph-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,34 @@ pub use agent::SendAgentSpawner;
#[cfg(feature = "acp-http")]
pub use transport::{AcpHttpState, acp_router};

// Crate-major-version bumps of `agent-client-protocol` must never silently change the ACP
// *wire* protocol version Zeph advertises. With `unstable_protocol_v2` off (never forwarded by
// Zeph — see the `agent-client-protocol-schema` feature list), `LATEST` is hardcoded to `V1` by
// the pinned schema crate, so this assertion is a tautology for the version pinned today; its
// value is as a regression guard against a *future* schema-pin bump that redefines `LATEST` to
// something other than `1` — that failure mode would otherwise only surface as a silent wire
// behavior change, not a compile error.
const _: () = assert!(
agent_client_protocol::schema::ProtocolVersion::LATEST.as_u16() == 1,
"ACP wire protocol version must stay pinned at 1 across agent-client-protocol crate bumps"
);

/// Wire protocol type for an LLM provider, used to populate [`AcpServerConfig::provider_names`].
#[cfg(feature = "unstable-llm-providers")]
pub use agent_client_protocol_schema::v1::LlmProtocol;

#[cfg(test)]
mod tests {
// Deliberately hardcodes the literal `1` rather than deriving it from `ProtocolVersion::LATEST`
// (unlike the `const _` guard above and `discovery_returns_expected_json_fields`, which both
// compare against the live symbol and would therefore stay green even if `LATEST` were
// silently redefined). This is the one check in the suite that fails if someone weakens the
// wire-version invariant itself.
#[test]
fn protocol_version_latest_is_hardcoded_wire_v1() {
assert_eq!(
agent_client_protocol::schema::ProtocolVersion::LATEST.as_u16(),
1
);
}
}
80 changes: 80 additions & 0 deletions crates/zeph-acp/src/transport/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,86 @@ async fn post_with_existing_session_id_reuses_connection() {
);
}

/// agent-client-protocol 2.0.0 added standard-transport support for JSON-RPC batches (an array
/// body instead of a single object). `post_handler` relays the raw HTTP body byte-for-byte into
/// the connection's duplex writer (`transport/http.rs`), so a batch now reaches the SDK's
/// dispatch loop end-to-end without any Zeph-side code change. This test proves both batch
/// entries are actually dispatched and individually answered — not silently dropped or
/// short-circuited after the first entry — by checking the response carries both request ids
/// (see spec.md "Breaking Changes Resolution (SDK 1.2.0 -> 2.0.0)"). Both entries below
/// deliberately omit `params` so both come back as individual `Invalid params` errors rather
/// than a real handshake — dispatch/response-tracking is what's under test, not `initialize`
/// semantics, and the SDK aggregates all batch replies into a single JSON array on one SSE line
/// (confirmed empirically), not one line per entry.
#[tokio::test]
async fn post_batch_body_dispatches_all_entries_and_returns_all_responses() {
use std::collections::HashSet;
use std::time::Duration;

use futures::StreamExt as _;

// Keep `state` alive for the whole test: it owns the `connections` map, which in turn owns
// the duplex-pipe writer half and the broadcast sender the SSE stream reads from. Passing
// only `acp_router(test_state())` inline would drop `state` (and close the pipe) as soon as
// `oneshot()` returns, before the SSE body below is ever polled.
let state = test_state();
let router = acp_router(state.clone());

let batch = r#"[{"jsonrpc":"2.0","id":1,"method":"initialize"},{"jsonrpc":"2.0","id":2,"method":"initialize"}]"#;
let req = Request::builder()
.method("POST")
.uri("/acp")
.header("content-type", "application/json")
.body(Body::from(batch))
.unwrap();

let response = router.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);

let mut stream = response.into_body().into_data_stream();
let mut buf = String::new();
let mut received_ids: HashSet<u64> = HashSet::new();
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);

while received_ids.len() < 2 {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
assert!(
remaining > Duration::ZERO,
"timed out waiting for 2 batch responses, got {received_ids:?} so far"
);
let chunk = tokio::time::timeout(remaining, stream.next())
.await
.expect("timed out waiting for next SSE chunk")
.expect("SSE stream ended before both batch responses arrived")
.expect("SSE stream error");
buf.push_str(&String::from_utf8_lossy(&chunk));

while let Some(pos) = buf.find('\n') {
let line = buf[..pos].to_owned();
buf.drain(..=pos);
let Some(data) = line.strip_prefix("data:") else {
continue;
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(data.trim()) else {
continue;
};
// Batch replies arrive aggregated as a single JSON array; a non-batch single-object
// reply (defensive fallback, in case framing ever changes) is handled too.
let entries: Vec<&serde_json::Value> = match &json {
serde_json::Value::Array(entries) => entries.iter().collect(),
other => vec![other],
};
for entry in entries {
if let Some(id) = entry.get("id").and_then(serde_json::Value::as_u64) {
received_ids.insert(id);
}
}
}
}

assert_eq!(received_ids, HashSet::from([1, 2]));
}

#[tokio::test]
async fn post_with_unknown_session_id_returns_not_found() {
let router = acp_router(test_state());
Expand Down
Loading
Loading