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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## 0.4.6 (2025-10-10)

### Protocol

- No changes

### Rust

- Fix: support all valid JSON-RPC ids (int, string, null)

## 0.4.5 (2025-10-02)

### Protocol
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "agent-client-protocol"
authors = ["Zed <hi@zed.dev>"]
version = "0.4.5"
version = "0.4.6"
edition = "2024"
license = "Apache-2.0"
description = "A protocol for standardizing communication between code editors and AI coding agents"
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@agentclientprotocol/sdk",
"version": "0.4.5",
"version": "0.4.6",
"publishConfig": {
"access": "public"
},
Expand Down
76 changes: 63 additions & 13 deletions rust/rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use std::{
rc::Rc,
sync::{
Arc,
atomic::{AtomicI32, Ordering},
atomic::{AtomicI64, Ordering},
},
};

Expand All @@ -29,8 +29,8 @@ use crate::{Error, StreamReceiver};

pub struct RpcConnection<Local: Side, Remote: Side> {
outgoing_tx: UnboundedSender<OutgoingMessage<Local, Remote>>,
pending_responses: Arc<Mutex<HashMap<i32, PendingResponse>>>,
next_id: AtomicI32,
pending_responses: Arc<Mutex<HashMap<Id, PendingResponse>>>,
next_id: AtomicI64,
broadcast: StreamBroadcast,
}

Expand Down Expand Up @@ -81,7 +81,7 @@ where
let this = Self {
outgoing_tx,
pending_responses,
next_id: AtomicI32::new(0),
next_id: AtomicI64::new(0),
broadcast,
};

Expand Down Expand Up @@ -112,8 +112,9 @@ where
) -> impl Future<Output = Result<Out, Error>> {
let (tx, rx) = oneshot::channel();
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
let id = Id::Number(id);
self.pending_responses.lock().insert(
id,
id.clone(),
PendingResponse {
deserialize: |value| {
serde_json::from_str::<Out>(value.get())
Expand All @@ -129,7 +130,7 @@ where
if self
.outgoing_tx
.unbounded_send(OutgoingMessage::Request {
id,
id: id.clone(),
method: method.into(),
params,
})
Expand All @@ -153,7 +154,7 @@ where
mut outgoing_rx: UnboundedReceiver<OutgoingMessage<Local, Remote>>,
mut outgoing_bytes: impl Unpin + AsyncWrite,
incoming_bytes: impl Unpin + AsyncRead,
pending_responses: Arc<Mutex<HashMap<i32, PendingResponse>>>,
pending_responses: Arc<Mutex<HashMap<Id, PendingResponse>>>,
broadcast: StreamSender,
) -> Result<()> {
// TODO: Create nicer abstraction for broadcast
Expand Down Expand Up @@ -187,7 +188,7 @@ where
// Request
match Local::decode_request(method, message.params) {
Ok(request) => {
broadcast.incoming_request(id, method, &request);
broadcast.incoming_request(id.clone(), method, &request);
incoming_tx.unbounded_send(IncomingMessage::Request { id, request }).ok();
}
Err(err) => {
Expand Down Expand Up @@ -222,7 +223,7 @@ where
pending_response.respond.send(result).ok();
}
} else {
log::error!("received response for unknown request id: {id}");
log::error!("received response for unknown request id: {id:?}");
}
} else if let Some(method) = message.method {
// Notification
Expand Down Expand Up @@ -297,31 +298,41 @@ where
}
}

/// JSON RPC Request Id
#[derive(Debug, PartialEq, Clone, Hash, Eq, Deserialize, Serialize, PartialOrd, Ord)]
#[serde(deny_unknown_fields)]
#[serde(untagged)]
pub enum Id {
Null,
Number(i64),
Str(String),
}

#[derive(Deserialize)]
struct RawIncomingMessage<'a> {
id: Option<i32>,
id: Option<Id>,
method: Option<&'a str>,
params: Option<&'a RawValue>,
result: Option<&'a RawValue>,
error: Option<Error>,
}

enum IncomingMessage<Local: Side> {
Request { id: i32, request: Local::InRequest },
Request { id: Id, request: Local::InRequest },
Notification { notification: Local::InNotification },
}

#[derive(Serialize, Deserialize, Clone)]
#[serde(untagged)]
pub enum OutgoingMessage<Local: Side, Remote: Side> {
Request {
id: i32,
id: Id,
method: Arc<str>,
#[serde(skip_serializing_if = "Option::is_none")]
params: Option<Remote::InRequest>,
},
Response {
id: i32,
id: Id,
#[serde(flatten)]
result: ResponseResult<Local::OutResponse>,
},
Expand Down Expand Up @@ -400,3 +411,42 @@ pub trait MessageHandler<Local: Side> {
notification: Local::InNotification,
) -> impl Future<Output = Result<(), Error>>;
}

#[cfg(test)]
mod tests {
use super::*;

use serde_json::{Number, Value};

#[test]
fn id_deserialization() {
let id = serde_json::from_value::<Id>(Value::Null).unwrap();
assert_eq!(id, Id::Null);

let id =
serde_json::from_value::<Id>(Value::Number(Number::from_u128(1).unwrap())).unwrap();
assert_eq!(id, Id::Number(1));

let id =
serde_json::from_value::<Id>(Value::Number(Number::from_i128(-1).unwrap())).unwrap();
assert_eq!(id, Id::Number(-1));

let id = serde_json::from_value::<Id>(Value::String("id".to_owned())).unwrap();
assert_eq!(id, Id::Str("id".to_owned()));
}

#[test]
fn id_serialization() {
let id = serde_json::to_value(Id::Null).unwrap();
assert_eq!(id, Value::Null);

let id = serde_json::to_value(Id::Number(1)).unwrap();
assert_eq!(id, Value::Number(Number::from_u128(1).unwrap()));

let id = serde_json::to_value(Id::Number(-1)).unwrap();
assert_eq!(id, Value::Number(Number::from_i128(-1).unwrap()));

let id = serde_json::to_value(Id::Str("id".to_owned())).unwrap();
assert_eq!(id, Value::String("id".to_owned()));
}
}
14 changes: 7 additions & 7 deletions rust/stream_broadcast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use serde_json::value::RawValue;

use crate::{
Error,
rpc::{OutgoingMessage, ResponseResult, Side},
rpc::{Id, OutgoingMessage, ResponseResult, Side},
};

/// A message that flows through the RPC stream.
Expand Down Expand Up @@ -50,7 +50,7 @@ pub enum StreamMessageContent {
/// A JSON-RPC request message.
Request {
/// The unique identifier for this request.
id: i32,
id: Id,
/// The name of the method being called.
method: Arc<str>,
/// Optional parameters for the method.
Expand All @@ -59,7 +59,7 @@ pub enum StreamMessageContent {
/// A JSON-RPC response message.
Response {
/// The ID of the request this response is for.
id: i32,
id: Id,
/// The result of the request (success or error).
result: Result<Option<serde_json::Value>, Error>,
},
Expand Down Expand Up @@ -124,12 +124,12 @@ impl StreamSender {
direction: StreamMessageDirection::Outgoing,
message: match message {
OutgoingMessage::Request { id, method, params } => StreamMessageContent::Request {
id: *id,
id: id.clone(),
method: method.clone(),
params: serde_json::to_value(params).ok(),
},
OutgoingMessage::Response { id, result } => StreamMessageContent::Response {
id: *id,
id: id.clone(),
result: match result {
ResponseResult::Result(value) => Ok(serde_json::to_value(value).ok()),
ResponseResult::Error(error) => Err(error.clone()),
Expand All @@ -150,7 +150,7 @@ impl StreamSender {
/// Broadcasts an incoming request to all receivers.
pub(crate) fn incoming_request(
&self,
id: i32,
id: Id,
method: impl Into<Arc<str>>,
params: &impl Serialize,
) {
Expand All @@ -171,7 +171,7 @@ impl StreamSender {
}

/// Broadcasts an incoming response to all receivers.
pub(crate) fn incoming_response(&self, id: i32, result: Result<Option<&RawValue>, &Error>) {
pub(crate) fn incoming_response(&self, id: Id, result: Result<Option<&RawValue>, &Error>) {
if self.0.receiver_count() == 0 {
return;
}
Expand Down
2 changes: 1 addition & 1 deletion typescript/acp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -690,7 +690,7 @@ export class ClientSideConnection implements Agent {
export type { AnyMessage } from "./jsonrpc.js";

class Connection {
#pendingResponses: Map<string | number, PendingResponse> = new Map();
#pendingResponses: Map<string | number | null, PendingResponse> = new Map();
#nextRequestId: number = 0;
#requestHandler: RequestHandler;
#notificationHandler: NotificationHandler;
Expand Down
4 changes: 2 additions & 2 deletions typescript/jsonrpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ export type AnyMessage = AnyRequest | AnyResponse | AnyNotification;

export type AnyRequest = {
jsonrpc: "2.0";
id: string | number;
id: string | number | null;
method: string;
params?: unknown;
};

export type AnyResponse = {
jsonrpc: "2.0";
id: string | number;
id: string | number | null;
} & Result<unknown>;

export type AnyNotification = {
Expand Down