Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
103
crates/codegen/xai-acp-lib/src/channel.rs
Normal file
103
crates/codegen/xai-acp-lib/src/channel.rs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
use std::fmt;
|
||||
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
use crate::{
|
||||
common::{AcpChannelFailure, AcpResult, acp_channel_failure_error},
|
||||
message::{AcpAgentMessage, AcpArgs, AcpClientMessage, AcpMethod, AcpRequest},
|
||||
};
|
||||
|
||||
/// Receiver/sender pair, either for client/agent or agent/client message types.
|
||||
pub struct AcpChannel<I, O> {
|
||||
pub rx: mpsc::UnboundedReceiver<I>,
|
||||
pub tx: mpsc::UnboundedSender<O>,
|
||||
}
|
||||
|
||||
impl<I: AcpMethod, O: AcpMethod> AcpChannel<I, O> {
|
||||
pub fn new(rx: mpsc::UnboundedReceiver<I>, tx: mpsc::UnboundedSender<O>) -> Self {
|
||||
Self { rx, tx }
|
||||
}
|
||||
}
|
||||
|
||||
/// Client channel: receive client messages from agent, send agent messages to agent.
|
||||
pub type AcpClientChannel = AcpChannel<AcpClientMessage, AcpAgentMessage>;
|
||||
/// Agent channel: receive agent messages from client, send client messages to client.
|
||||
pub type AcpAgentChannel = AcpChannel<AcpAgentMessage, AcpClientMessage>;
|
||||
|
||||
/// Create a linked pair of client/agent channels.
|
||||
pub fn acp_channels() -> (AcpClientChannel, AcpAgentChannel) {
|
||||
let (tx1, rx1) = mpsc::unbounded_channel();
|
||||
let (tx2, rx2) = mpsc::unbounded_channel();
|
||||
(AcpChannel::new(rx1, tx2), AcpChannel::new(rx2, tx1))
|
||||
}
|
||||
|
||||
pub async fn acp_send<R, T>(request: T, tx: &mpsc::UnboundedSender<R>) -> AcpResult<T::Response>
|
||||
where
|
||||
T: AcpRequest,
|
||||
R: From<AcpArgs<T>> + fmt::Debug,
|
||||
{
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
let method = request.method_name();
|
||||
let args = AcpArgs {
|
||||
request,
|
||||
response_tx,
|
||||
};
|
||||
|
||||
tx.send(args.into()).map_err(|_| {
|
||||
acp_channel_failure_error(
|
||||
format!("unable to send '{method}' request, channel closed"),
|
||||
AcpChannelFailure::SendFailed,
|
||||
)
|
||||
})?;
|
||||
|
||||
response_rx.await.map_err(|_| {
|
||||
acp_channel_failure_error(
|
||||
format!("unable to receive '{method}' response, channel closed"),
|
||||
AcpChannelFailure::RecvFailed,
|
||||
)
|
||||
})?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod acp_send_failure_tests {
|
||||
use super::acp_send;
|
||||
use crate::common::{AcpChannelFailure, acp_channel_failure};
|
||||
use crate::message::AcpAgentMessage;
|
||||
use agent_client_protocol as acp;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
fn ext_request() -> acp::ExtRequest {
|
||||
acp::ExtRequest::new(
|
||||
"x.ai/test",
|
||||
serde_json::value::to_raw_value(&serde_json::json!({}))
|
||||
.unwrap()
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_failed_when_receiver_dropped_before_send() {
|
||||
let (tx, rx) = mpsc::unbounded_channel::<AcpAgentMessage>();
|
||||
drop(rx); // no peer listening -> enqueue fails
|
||||
let err = acp_send(ext_request(), &tx).await.unwrap_err();
|
||||
assert_eq!(
|
||||
acp_channel_failure(&err),
|
||||
Some(AcpChannelFailure::SendFailed)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recv_failed_when_response_channel_dropped_after_send() {
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<AcpAgentMessage>();
|
||||
let mut send_fut = Box::pin(acp_send(ext_request(), &tx));
|
||||
// First poll enqueues the request, then parks on the response channel.
|
||||
assert!(futures::poll!(send_fut.as_mut()).is_pending());
|
||||
// The peer "receives" the request then drops it (dropping response_tx).
|
||||
drop(rx.try_recv().expect("request should be enqueued"));
|
||||
let err = send_fut.await.unwrap_err();
|
||||
assert_eq!(
|
||||
acp_channel_failure(&err),
|
||||
Some(AcpChannelFailure::RecvFailed)
|
||||
);
|
||||
}
|
||||
}
|
||||
115
crates/codegen/xai-acp-lib/src/common.rs
Normal file
115
crates/codegen/xai-acp-lib/src/common.rs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
use agent_client_protocol as acp;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
use crate::message::{AcpAgentMessage, AcpClientMessage};
|
||||
|
||||
pub type AcpResult<T> = Result<T, acp::Error>;
|
||||
|
||||
pub type AcpRxo<T> = oneshot::Receiver<AcpResult<T>>;
|
||||
pub type AcpTxo<T> = oneshot::Sender<AcpResult<T>>;
|
||||
|
||||
pub type AcpClientRx = mpsc::UnboundedReceiver<AcpClientMessage>;
|
||||
pub type AcpClientTx = mpsc::UnboundedSender<AcpClientMessage>;
|
||||
|
||||
pub type AcpAgentRx = mpsc::UnboundedReceiver<AcpAgentMessage>;
|
||||
pub type AcpAgentTx = mpsc::UnboundedSender<AcpAgentMessage>;
|
||||
|
||||
pub fn acp_internal_error(message: impl Into<String>) -> acp::Error {
|
||||
acp::Error::new(acp::ErrorCode::InternalError.into(), message)
|
||||
}
|
||||
|
||||
/// The two distinct ways an [`acp_send`](crate::acp_send) round-trip can fail
|
||||
/// when the underlying channel is closed. Both surface as a JSON-RPC
|
||||
/// `INTERNAL_ERROR` (so existing callers and the wire format are unaffected);
|
||||
/// this typed discriminant — carried in the error's `data` — lets callers tell
|
||||
/// them apart WITHOUT substring-matching the human-readable `message`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AcpChannelFailure {
|
||||
/// The request could not be ENQUEUED: the receiver half (the peer's
|
||||
/// connection task) is already gone, so no peer is listening — e.g. a
|
||||
/// headless run with no client wired.
|
||||
SendFailed,
|
||||
/// The request was enqueued but the RESPONSE channel was dropped before a
|
||||
/// reply arrived: a peer received the request, then went away (disconnect /
|
||||
/// process exit) without answering.
|
||||
RecvFailed,
|
||||
}
|
||||
|
||||
impl AcpChannelFailure {
|
||||
/// `data` object key under which [`acp_send`](crate::acp_send) records the
|
||||
/// kind. Namespaced so it can never collide with other `with_data` payloads.
|
||||
const DATA_KEY: &'static str = "xaiAcpChannelFailure";
|
||||
|
||||
const fn tag(self) -> &'static str {
|
||||
match self {
|
||||
Self::SendFailed => "send_failed",
|
||||
Self::RecvFailed => "recv_failed",
|
||||
}
|
||||
}
|
||||
|
||||
fn from_tag(tag: &str) -> Option<Self> {
|
||||
match tag {
|
||||
"send_failed" => Some(Self::SendFailed),
|
||||
"recv_failed" => Some(Self::RecvFailed),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the channel-closed error for [`acp_send`](crate::acp_send), tagging it
|
||||
/// with a typed [`AcpChannelFailure`] discriminant in `data`. The error `code`
|
||||
/// stays `INTERNAL_ERROR`, so this is purely additive for callers that just
|
||||
/// propagate the error.
|
||||
pub(crate) fn acp_channel_failure_error(
|
||||
message: impl Into<String>,
|
||||
kind: AcpChannelFailure,
|
||||
) -> acp::Error {
|
||||
acp_internal_error(message).data(serde_json::json!({ AcpChannelFailure::DATA_KEY: kind.tag() }))
|
||||
}
|
||||
|
||||
/// Recover the [`AcpChannelFailure`] kind from an error, or `None` if the error
|
||||
/// did not originate from [`acp_send`](crate::acp_send)'s channel-closed paths
|
||||
/// (or predates the tag). Consumers use this instead of inspecting `message`.
|
||||
pub fn acp_channel_failure(err: &acp::Error) -> Option<AcpChannelFailure> {
|
||||
err.data
|
||||
.as_ref()
|
||||
.and_then(|data| data.get(AcpChannelFailure::DATA_KEY))
|
||||
.and_then(|value| value.as_str())
|
||||
.and_then(AcpChannelFailure::from_tag)
|
||||
}
|
||||
|
||||
/// Compact single-line JSON for gateway debug traces. Plain (uncolored)
|
||||
/// output: this feeds `tracing::debug!`, which typically lands in log files
|
||||
/// where ANSI colors are noise. Replaces the former `colored_json`-backed
|
||||
/// `color_json` (dropped to shrink the shipped dependency tree).
|
||||
#[doc(hidden)]
|
||||
pub fn compact_json<T: serde::Serialize>(value: &T) -> String {
|
||||
serde_json::to_string(value).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod channel_failure_tests {
|
||||
use super::{
|
||||
AcpChannelFailure, acp, acp_channel_failure, acp_channel_failure_error, acp_internal_error,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn classifier_round_trips_both_kinds() {
|
||||
for kind in [AcpChannelFailure::SendFailed, AcpChannelFailure::RecvFailed] {
|
||||
let err = acp_channel_failure_error("boom", kind);
|
||||
// Code stays INTERNAL_ERROR for backward compatibility.
|
||||
assert_eq!(err.code, acp::ErrorCode::InternalError);
|
||||
assert_eq!(acp_channel_failure(&err), Some(kind));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifier_none_for_untagged_errors() {
|
||||
assert_eq!(acp_channel_failure(&acp_internal_error("plain")), None);
|
||||
// A different `with_data` payload must not be misread as a channel kind.
|
||||
assert_eq!(
|
||||
acp_channel_failure(&acp::Error::invalid_params().data("unknown session id")),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
695
crates/codegen/xai-acp-lib/src/gateway.rs
Normal file
695
crates/codegen/xai-acp-lib/src/gateway.rs
Normal file
|
|
@ -0,0 +1,695 @@
|
|||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::rc::Rc;
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tracing::Instrument;
|
||||
|
||||
use crate::{
|
||||
AcpMethod, acp_send,
|
||||
common::AcpResult,
|
||||
message::{AcpAgentMessage, AcpArgs, AcpClientMessage, AcpRequest, AcpSide},
|
||||
};
|
||||
|
||||
type SpawnFn = Rc<dyn Fn(Pin<Box<dyn Future<Output = ()>>>)>;
|
||||
/// Callback that creates a `tracing::Span` from `_meta` for distributed tracing.
|
||||
type OnMetaFn = Rc<dyn Fn(&acp::Meta) -> tracing::Span>;
|
||||
|
||||
/// Gateway receiver - allows sending messages to it via a channel and it will
|
||||
/// forward them to an underlying connection.
|
||||
pub struct AcpGatewayReceiver<S: AcpSide, C> {
|
||||
rx: mpsc::UnboundedReceiver<S::OutMessage>,
|
||||
conn: C,
|
||||
tracing: bool,
|
||||
spawn_fn: SpawnFn,
|
||||
on_meta: Option<OnMetaFn>,
|
||||
}
|
||||
|
||||
impl<S: AcpSide, C> AcpGatewayReceiver<S, C> {
|
||||
pub fn new(rx: mpsc::UnboundedReceiver<S::OutMessage>, conn: C) -> Self {
|
||||
Self {
|
||||
rx,
|
||||
conn,
|
||||
tracing: false,
|
||||
spawn_fn: Rc::new(|fut| {
|
||||
tokio::task::spawn_local(fut);
|
||||
}),
|
||||
on_meta: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_tracing(mut self, tracing: bool) -> Self {
|
||||
self.tracing = tracing;
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the spawner used for dispatching incoming messages.
|
||||
///
|
||||
/// By default, `spawn_local` is used (suitable for `LocalSet` runtimes).
|
||||
/// Pass a custom spawner to use a different execution strategy.
|
||||
pub fn with_spawn_fn(
|
||||
mut self,
|
||||
f: impl Fn(Pin<Box<dyn Future<Output = ()>>>) + 'static,
|
||||
) -> Self {
|
||||
self.spawn_fn = Rc::new(f);
|
||||
self
|
||||
}
|
||||
|
||||
/// Hook that builds a `tracing::Span` from `_meta` to `.instrument()` dispatched messages.
|
||||
pub fn with_on_meta(mut self, f: impl Fn(&acp::Meta) -> tracing::Span + 'static) -> Self {
|
||||
self.on_meta = Some(Rc::new(f));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The other side of the gateway. Allows to send messages to a channel so that
|
||||
/// they will be forwarded automatically to a connection (as long as gateway
|
||||
/// receiver side is running in the background).
|
||||
pub struct AcpGatewaySender<S: AcpSide> {
|
||||
tx: mpsc::UnboundedSender<S::OutMessage>,
|
||||
tracing: bool,
|
||||
}
|
||||
|
||||
impl<S: AcpSide> Clone for AcpGatewaySender<S> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
tx: self.tx.clone(),
|
||||
tracing: self.tracing,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: AcpSide> AcpGatewaySender<S> {
|
||||
pub fn new(tx: mpsc::UnboundedSender<S::OutMessage>) -> Self {
|
||||
Self { tx, tracing: false }
|
||||
}
|
||||
|
||||
pub fn tx(&self) -> mpsc::UnboundedSender<S::OutMessage> {
|
||||
self.tx.clone()
|
||||
}
|
||||
|
||||
pub fn with_tracing(mut self, tracing: bool) -> Self {
|
||||
self.tracing = tracing;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn acp_gateway<S: AcpSide, C>(conn: C) -> (AcpGatewaySender<S>, AcpGatewayReceiver<S, C>) {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let sender = AcpGatewaySender::new(tx);
|
||||
let receiver = AcpGatewayReceiver::new(rx, conn);
|
||||
(sender, receiver)
|
||||
}
|
||||
|
||||
pub type AcpAgentGatewayReceiver = AcpGatewayReceiver<acp::AgentSide, acp::AgentSideConnection>;
|
||||
pub type AcpAgentGatewaySender = AcpGatewaySender<acp::AgentSide>;
|
||||
pub type AcpClientGatewayReceiver = AcpGatewayReceiver<acp::ClientSide, acp::ClientSideConnection>;
|
||||
pub type AcpClientGatewaySender = AcpGatewaySender<acp::ClientSide>;
|
||||
|
||||
fn before_request<T: AcpRequest>(args: &AcpArgs<T>, tracing: bool) -> Option<String> {
|
||||
tracing.then(|| {
|
||||
let method = crate::common::compact_json(&args.method_name());
|
||||
tracing::debug!(
|
||||
"sending {method} request: {}",
|
||||
crate::common::compact_json(&args.request)
|
||||
);
|
||||
method
|
||||
})
|
||||
}
|
||||
|
||||
fn after_request<T: Serialize>(
|
||||
response_tx: oneshot::Sender<AcpResult<T>>,
|
||||
response: AcpResult<T>,
|
||||
method: Option<String>,
|
||||
) -> bool {
|
||||
if let Some(method) = method {
|
||||
match response {
|
||||
Ok(ref response) => {
|
||||
tracing::debug!(
|
||||
"received {method} response: {}",
|
||||
crate::common::compact_json(&response)
|
||||
);
|
||||
}
|
||||
Err(ref err) => {
|
||||
// Log at debug level - errors are handled visually in the TUI status bar
|
||||
tracing::debug!("received {method} error: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
response_tx.send(response).is_ok()
|
||||
}
|
||||
|
||||
macro_rules! handle {
|
||||
($args:expr, $tracing:expr, $conn:expr, $name:ident, $spawn:expr, $on_meta:expr $(,)?) => {{
|
||||
let span = ($on_meta)
|
||||
.as_ref()
|
||||
.zip(($args).request.meta.as_ref())
|
||||
.map(|(f, meta)| f(meta))
|
||||
.unwrap_or_else(tracing::Span::none);
|
||||
($spawn)(Box::pin(
|
||||
async move {
|
||||
let method = before_request(&($args), $tracing);
|
||||
let response = ($conn).$name(($args).request).await;
|
||||
let _ = after_request(($args).response_tx, response, method);
|
||||
}
|
||||
.instrument(span),
|
||||
));
|
||||
}};
|
||||
// Variant for types without `meta` field (ExtRequest, ExtNotification).
|
||||
// $on_meta is accepted (but unused) to disambiguate from the primary pattern.
|
||||
(no_meta, $args:expr, $tracing:expr, $conn:expr, $name:ident, $spawn:expr, $on_meta:expr $(,)?) => {
|
||||
($spawn)(Box::pin(async move {
|
||||
let method = before_request(&($args), $tracing);
|
||||
let response = ($conn).$name(($args).request).await;
|
||||
let _ = after_request(($args).response_tx, response, method);
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
impl<C: acp::Agent + 'static> AcpGatewayReceiver<acp::ClientSide, C> {
|
||||
pub async fn run(mut self) {
|
||||
let conn = Rc::new(self.conn);
|
||||
let spawn = self.spawn_fn.clone();
|
||||
let on_meta = self.on_meta.clone();
|
||||
while let Some(msg) = self.rx.recv().await {
|
||||
let conn = conn.clone();
|
||||
match msg {
|
||||
AcpAgentMessage::Initialize(args) => {
|
||||
handle!(args, self.tracing, conn, initialize, spawn, on_meta);
|
||||
}
|
||||
AcpAgentMessage::Authenticate(args) => {
|
||||
handle!(args, self.tracing, conn, authenticate, spawn, on_meta);
|
||||
}
|
||||
AcpAgentMessage::NewSession(args) => {
|
||||
handle!(args, self.tracing, conn, new_session, spawn, on_meta);
|
||||
}
|
||||
AcpAgentMessage::LoadSession(args) => {
|
||||
handle!(args, self.tracing, conn, load_session, spawn, on_meta);
|
||||
}
|
||||
AcpAgentMessage::SetSessionMode(args) => {
|
||||
handle!(args, self.tracing, conn, set_session_mode, spawn, on_meta);
|
||||
}
|
||||
AcpAgentMessage::Prompt(args) => {
|
||||
handle!(args, self.tracing, conn, prompt, spawn, on_meta);
|
||||
}
|
||||
AcpAgentMessage::Cancel(args) => {
|
||||
handle!(args, self.tracing, conn, cancel, spawn, on_meta);
|
||||
}
|
||||
AcpAgentMessage::ExtMethod(args) => {
|
||||
handle!(
|
||||
no_meta,
|
||||
args,
|
||||
self.tracing,
|
||||
conn,
|
||||
ext_method,
|
||||
spawn,
|
||||
on_meta
|
||||
);
|
||||
}
|
||||
AcpAgentMessage::ExtNotification(args) => {
|
||||
handle!(
|
||||
no_meta,
|
||||
args,
|
||||
self.tracing,
|
||||
conn,
|
||||
ext_notification,
|
||||
spawn,
|
||||
on_meta
|
||||
);
|
||||
}
|
||||
AcpAgentMessage::SetSessionModel(args) => {
|
||||
handle!(args, self.tracing, conn, set_session_model, spawn, on_meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.tracing {
|
||||
tracing::trace!("stopping gateway loop: receiver channel is closed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: acp::Client + 'static> AcpGatewayReceiver<acp::AgentSide, C> {
|
||||
pub async fn run(mut self) {
|
||||
let conn = Rc::new(self.conn);
|
||||
let spawn = self.spawn_fn.clone();
|
||||
let on_meta = self.on_meta.clone();
|
||||
while let Some(msg) = self.rx.recv().await {
|
||||
let conn = conn.clone();
|
||||
match msg {
|
||||
AcpClientMessage::RequestPermission(args) => {
|
||||
handle!(args, self.tracing, conn, request_permission, spawn, on_meta);
|
||||
}
|
||||
AcpClientMessage::ReadTextFile(args) => {
|
||||
handle!(args, self.tracing, conn, read_text_file, spawn, on_meta);
|
||||
}
|
||||
AcpClientMessage::WriteTextFile(args) => {
|
||||
handle!(args, self.tracing, conn, write_text_file, spawn, on_meta);
|
||||
}
|
||||
AcpClientMessage::SessionNotification(args) => {
|
||||
handle!(
|
||||
args,
|
||||
self.tracing,
|
||||
conn,
|
||||
session_notification,
|
||||
spawn,
|
||||
on_meta
|
||||
);
|
||||
}
|
||||
AcpClientMessage::CreateTerminal(args) => {
|
||||
handle!(args, self.tracing, conn, create_terminal, spawn, on_meta);
|
||||
}
|
||||
AcpClientMessage::TerminalOutput(args) => {
|
||||
handle!(args, self.tracing, conn, terminal_output, spawn, on_meta);
|
||||
}
|
||||
AcpClientMessage::ReleaseTerminal(args) => {
|
||||
handle!(args, self.tracing, conn, release_terminal, spawn, on_meta);
|
||||
}
|
||||
AcpClientMessage::WaitForTerminalExit(args) => {
|
||||
handle!(
|
||||
args,
|
||||
self.tracing,
|
||||
conn,
|
||||
wait_for_terminal_exit,
|
||||
spawn,
|
||||
on_meta
|
||||
);
|
||||
}
|
||||
AcpClientMessage::KillTerminalCommand(args) => {
|
||||
handle!(args, self.tracing, conn, kill_terminal, spawn, on_meta);
|
||||
}
|
||||
AcpClientMessage::ExtMethod(args) => {
|
||||
handle!(
|
||||
no_meta,
|
||||
args,
|
||||
self.tracing,
|
||||
conn,
|
||||
ext_method,
|
||||
spawn,
|
||||
on_meta
|
||||
);
|
||||
}
|
||||
AcpClientMessage::ExtNotification(args) => {
|
||||
handle!(
|
||||
no_meta,
|
||||
args,
|
||||
self.tracing,
|
||||
conn,
|
||||
ext_notification,
|
||||
spawn,
|
||||
on_meta
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.tracing {
|
||||
tracing::trace!("stopping gateway loop: receiver channel is closed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: AcpSide> AcpGatewaySender<S> {
|
||||
/// Shared enqueue for the forward variants; `caller` attributes the
|
||||
/// dropped-receiver log to the right public method.
|
||||
fn enqueue<T>(
|
||||
&self,
|
||||
request: T,
|
||||
caller: &'static str,
|
||||
) -> (bool, oneshot::Receiver<AcpResult<T::Response>>)
|
||||
where
|
||||
T: AcpRequest,
|
||||
S::OutMessage: From<AcpArgs<T>>,
|
||||
{
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
let method = request.method_name();
|
||||
let args = AcpArgs {
|
||||
request,
|
||||
response_tx,
|
||||
};
|
||||
let accepted = self.tx.send(args.into()).is_ok();
|
||||
if !accepted {
|
||||
tracing::debug!(method, "{caller}: receiver dropped, notification discarded");
|
||||
}
|
||||
(accepted, response_rx)
|
||||
}
|
||||
|
||||
/// Enqueue a request and return a completion receiver for handler finish.
|
||||
pub fn forward_with_completion<T>(
|
||||
&self,
|
||||
request: T,
|
||||
) -> oneshot::Receiver<AcpResult<T::Response>>
|
||||
where
|
||||
T: AcpRequest,
|
||||
S::OutMessage: From<AcpArgs<T>>,
|
||||
{
|
||||
self.enqueue(request, "forward_with_completion").1
|
||||
}
|
||||
|
||||
/// Enqueue a request without waiting for the response. Returns whether
|
||||
/// the gateway channel accepted it (`false`: receiver gone, message
|
||||
/// discarded) so callers keeping delivery-dependent state can retry.
|
||||
pub fn forward_fire_and_forget<T>(&self, request: T) -> bool
|
||||
where
|
||||
T: AcpRequest,
|
||||
S::OutMessage: From<AcpArgs<T>>,
|
||||
{
|
||||
self.enqueue(request, "forward_fire_and_forget").0
|
||||
}
|
||||
|
||||
/// Send a request and await the response. Returns a `Send` future.
|
||||
///
|
||||
/// Equivalent to the `acp::Client` / `acp::Agent` trait methods but the
|
||||
/// returned future is `Send` because this is an inherent async fn — not
|
||||
/// wrapped by `#[async_trait(?Send)]`.
|
||||
pub async fn send<T>(&self, request: T) -> AcpResult<T::Response>
|
||||
where
|
||||
T: AcpRequest,
|
||||
S::OutMessage: From<AcpArgs<T>>,
|
||||
{
|
||||
self.forward(request).await
|
||||
}
|
||||
|
||||
async fn forward<T>(&self, request: T) -> AcpResult<T::Response>
|
||||
where
|
||||
T: AcpRequest,
|
||||
S::OutMessage: From<AcpArgs<T>>,
|
||||
{
|
||||
if self.tracing {
|
||||
let method = crate::common::compact_json(&request.method_name());
|
||||
tracing::debug!(
|
||||
"received {method} request: {}",
|
||||
crate::common::compact_json(&request)
|
||||
);
|
||||
}
|
||||
acp_send(request, &self.tx).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl acp::Client for AcpGatewaySender<acp::AgentSide> {
|
||||
async fn request_permission(
|
||||
&self,
|
||||
args: acp::RequestPermissionRequest,
|
||||
) -> AcpResult<acp::RequestPermissionResponse> {
|
||||
self.forward(args).await
|
||||
}
|
||||
|
||||
async fn write_text_file(
|
||||
&self,
|
||||
args: acp::WriteTextFileRequest,
|
||||
) -> AcpResult<acp::WriteTextFileResponse> {
|
||||
self.forward(args).await
|
||||
}
|
||||
|
||||
async fn read_text_file(
|
||||
&self,
|
||||
args: acp::ReadTextFileRequest,
|
||||
) -> AcpResult<acp::ReadTextFileResponse> {
|
||||
self.forward(args).await
|
||||
}
|
||||
|
||||
async fn create_terminal(
|
||||
&self,
|
||||
args: acp::CreateTerminalRequest,
|
||||
) -> AcpResult<acp::CreateTerminalResponse> {
|
||||
self.forward(args).await
|
||||
}
|
||||
|
||||
async fn terminal_output(
|
||||
&self,
|
||||
args: acp::TerminalOutputRequest,
|
||||
) -> AcpResult<acp::TerminalOutputResponse> {
|
||||
self.forward(args).await
|
||||
}
|
||||
|
||||
async fn release_terminal(
|
||||
&self,
|
||||
args: acp::ReleaseTerminalRequest,
|
||||
) -> AcpResult<acp::ReleaseTerminalResponse> {
|
||||
self.forward(args).await
|
||||
}
|
||||
|
||||
async fn wait_for_terminal_exit(
|
||||
&self,
|
||||
args: acp::WaitForTerminalExitRequest,
|
||||
) -> AcpResult<acp::WaitForTerminalExitResponse> {
|
||||
self.forward(args).await
|
||||
}
|
||||
|
||||
async fn kill_terminal(
|
||||
&self,
|
||||
args: acp::KillTerminalRequest,
|
||||
) -> AcpResult<acp::KillTerminalResponse> {
|
||||
self.forward(args).await
|
||||
}
|
||||
|
||||
async fn session_notification(&self, args: acp::SessionNotification) -> AcpResult<()> {
|
||||
// Fire-and-forget: session notifications carry no meaningful response (the
|
||||
// ACK is `()`), so we must not block the caller waiting for the client to
|
||||
// acknowledge. When the agent→relay→client path is degraded (e.g. a Slack
|
||||
// session whose ephemeral WebSocket died mid-turn), the relay write can
|
||||
// stall for minutes (TCP retransmit timeout). Blocking here freezes the
|
||||
// terminal streaming loop — its timeout check never fires, the session
|
||||
// actor can't process new prompts, and the entire session hangs.
|
||||
self.forward_fire_and_forget(args);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ext_method(&self, args: acp::ExtRequest) -> AcpResult<acp::ExtResponse> {
|
||||
self.forward(args).await
|
||||
}
|
||||
|
||||
async fn ext_notification(&self, args: acp::ExtNotification) -> AcpResult<()> {
|
||||
// Fire-and-forget for the same reason as `session_notification` above:
|
||||
// the ACK is `()` and blocking risks hanging the caller when the
|
||||
// relay→client path is degraded. Many call sites already bypass this
|
||||
// trait method and call `forward_fire_and_forget` directly.
|
||||
self.forward_fire_and_forget(args);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl acp::Agent for AcpGatewaySender<acp::ClientSide> {
|
||||
async fn initialize(&self, args: acp::InitializeRequest) -> AcpResult<acp::InitializeResponse> {
|
||||
self.forward(args).await
|
||||
}
|
||||
|
||||
async fn authenticate(
|
||||
&self,
|
||||
args: acp::AuthenticateRequest,
|
||||
) -> AcpResult<acp::AuthenticateResponse> {
|
||||
self.forward(args).await
|
||||
}
|
||||
|
||||
async fn new_session(
|
||||
&self,
|
||||
args: acp::NewSessionRequest,
|
||||
) -> AcpResult<acp::NewSessionResponse> {
|
||||
self.forward(args).await
|
||||
}
|
||||
|
||||
async fn load_session(
|
||||
&self,
|
||||
args: acp::LoadSessionRequest,
|
||||
) -> AcpResult<acp::LoadSessionResponse> {
|
||||
self.forward(args).await
|
||||
}
|
||||
|
||||
async fn set_session_mode(
|
||||
&self,
|
||||
args: acp::SetSessionModeRequest,
|
||||
) -> AcpResult<acp::SetSessionModeResponse> {
|
||||
self.forward(args).await
|
||||
}
|
||||
|
||||
async fn prompt(&self, args: acp::PromptRequest) -> AcpResult<acp::PromptResponse> {
|
||||
self.forward(args).await
|
||||
}
|
||||
|
||||
async fn cancel(&self, args: acp::CancelNotification) -> AcpResult<()> {
|
||||
self.forward(args).await
|
||||
}
|
||||
|
||||
async fn ext_method(&self, args: acp::ExtRequest) -> AcpResult<acp::ExtResponse> {
|
||||
self.forward(args).await
|
||||
}
|
||||
|
||||
async fn ext_notification(&self, args: acp::ExtNotification) -> AcpResult<()> {
|
||||
self.forward(args).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
|
||||
struct OrderTrackingClient {
|
||||
log: Rc<RefCell<Vec<String>>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl acp::Client for OrderTrackingClient {
|
||||
async fn request_permission(
|
||||
&self,
|
||||
_: acp::RequestPermissionRequest,
|
||||
) -> acp::Result<acp::RequestPermissionResponse> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn session_notification(&self, args: acp::SessionNotification) -> acp::Result<()> {
|
||||
if let acp::SessionUpdate::AgentMessageChunk(chunk) = &args.update
|
||||
&& let acp::ContentBlock::Text(text) = &chunk.content
|
||||
{
|
||||
self.log.borrow_mut().push(text.text.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn text_notification(marker: &str) -> acp::SessionNotification {
|
||||
acp::SessionNotification::new(
|
||||
acp::SessionId::new("s"),
|
||||
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
|
||||
acp::TextContent::new(marker),
|
||||
))),
|
||||
)
|
||||
}
|
||||
|
||||
/// Regression: draining completion receivers preserves notification ordering.
|
||||
#[tokio::test]
|
||||
async fn completion_drain_preserves_notification_ordering() {
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
let log = Rc::new(RefCell::new(Vec::<String>::new()));
|
||||
let (sender, receiver) =
|
||||
acp_gateway::<acp::AgentSide, _>(OrderTrackingClient { log: log.clone() });
|
||||
tokio::task::spawn_local(receiver.run());
|
||||
|
||||
const N: usize = 100;
|
||||
let completions: Vec<_> = (0..N)
|
||||
.map(|i| sender.forward_with_completion(text_notification(&format!("{i}"))))
|
||||
.collect();
|
||||
for rx in completions {
|
||||
let _ = rx.await;
|
||||
}
|
||||
|
||||
log.borrow_mut().push("RESPONSE".into());
|
||||
|
||||
let log = log.borrow();
|
||||
assert_eq!(log.len(), N + 1);
|
||||
assert_eq!(log[N], "RESPONSE");
|
||||
for i in 0..N {
|
||||
assert_eq!(log[i], format!("{i}"));
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Regression: two-phase cutover keeps replay-before-response and avoids
|
||||
/// dropping live updates during drain.
|
||||
#[tokio::test]
|
||||
async fn two_phase_cutover_no_missing_updates() {
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
let log = Rc::new(RefCell::new(Vec::<String>::new()));
|
||||
let (sender, receiver) =
|
||||
acp_gateway::<acp::AgentSide, _>(OrderTrackingClient { log: log.clone() });
|
||||
tokio::task::spawn_local(receiver.run());
|
||||
|
||||
const DELTA: usize = 50;
|
||||
const LIVE: usize = 20;
|
||||
|
||||
// Phase 1: sync enqueue of replay notifications.
|
||||
let completions: Vec<_> = (0..DELTA)
|
||||
.map(|i| {
|
||||
sender.forward_with_completion(text_notification(&format!("delta-{i}")))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Gate-open point; then concurrent producer emits live updates.
|
||||
let live_sender = sender.clone();
|
||||
let producer = tokio::task::spawn_local(async move {
|
||||
for i in 0..LIVE {
|
||||
live_sender
|
||||
.forward_fire_and_forget(text_notification(&format!("live-{i}")));
|
||||
// Encourage interleaving with drain.
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
});
|
||||
|
||||
// Drain replay completions while producer runs.
|
||||
for rx in completions {
|
||||
let _ = rx.await;
|
||||
}
|
||||
|
||||
// Mark response boundary.
|
||||
log.borrow_mut().push("RESPONSE".into());
|
||||
|
||||
// Let producer and gateway finish remaining live updates.
|
||||
let _ = producer.await;
|
||||
for _ in 0..LIVE + 5 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
|
||||
let log = log.borrow();
|
||||
let response_idx = log
|
||||
.iter()
|
||||
.position(|s| s == "RESPONSE")
|
||||
.expect("RESPONSE marker must be in the log");
|
||||
|
||||
// (1) Delta notifications are all present and before RESPONSE.
|
||||
for i in 0..DELTA {
|
||||
let tag = format!("delta-{i}");
|
||||
let pos = log
|
||||
.iter()
|
||||
.position(|s| s == &tag)
|
||||
.unwrap_or_else(|| panic!("missing delta notification: {tag}"));
|
||||
assert!(
|
||||
pos < response_idx,
|
||||
"{tag} at index {pos} must precede RESPONSE at index {response_idx}"
|
||||
);
|
||||
}
|
||||
|
||||
// (2) Delta notifications preserve enqueue order.
|
||||
let delta_positions: Vec<usize> = (0..DELTA)
|
||||
.map(|i| log.iter().position(|s| s == &format!("delta-{i}")).unwrap())
|
||||
.collect();
|
||||
for w in delta_positions.windows(2) {
|
||||
assert!(
|
||||
w[0] < w[1],
|
||||
"delta ordering violated: delta at index {} came after delta at index {}",
|
||||
w[0],
|
||||
w[1]
|
||||
);
|
||||
}
|
||||
|
||||
// (3) No live updates are lost.
|
||||
for i in 0..LIVE {
|
||||
let tag = format!("live-{i}");
|
||||
assert!(
|
||||
log.iter().any(|s| s == &tag),
|
||||
"live update lost: {tag} not found in log"
|
||||
);
|
||||
}
|
||||
|
||||
// (4) Live updates do not precede replay delta.
|
||||
let last_delta = *delta_positions.last().unwrap();
|
||||
for i in 0..LIVE {
|
||||
let tag = format!("live-{i}");
|
||||
let pos = log.iter().position(|s| s == &tag).unwrap();
|
||||
assert!(
|
||||
pos > last_delta,
|
||||
"{tag} at index {pos} must come after last delta at index {last_delta}"
|
||||
);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
30
crates/codegen/xai-acp-lib/src/lib.rs
Normal file
30
crates/codegen/xai-acp-lib/src/lib.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
mod channel;
|
||||
mod common;
|
||||
mod gateway;
|
||||
mod line_reader;
|
||||
mod message;
|
||||
mod normalize;
|
||||
mod stdin_reader;
|
||||
|
||||
pub use self::{
|
||||
channel::{AcpAgentChannel, AcpChannel, AcpClientChannel, acp_channels, acp_send},
|
||||
common::{
|
||||
AcpAgentRx, AcpAgentTx, AcpChannelFailure, AcpClientRx, AcpClientTx, AcpResult, AcpRxo,
|
||||
AcpTxo, acp_channel_failure, acp_internal_error,
|
||||
},
|
||||
gateway::{
|
||||
AcpAgentGatewayReceiver, AcpAgentGatewaySender, AcpClientGatewayReceiver,
|
||||
AcpClientGatewaySender, AcpGatewayReceiver, AcpGatewaySender, acp_gateway,
|
||||
},
|
||||
message::{
|
||||
AcpAgentMessage, AcpAgentMessageBox, AcpAgentMessageGeneric, AcpArgs, AcpArgsBox,
|
||||
AcpClientMessage, AcpClientMessageBox, AcpClientMessageGeneric, AcpMethod, AcpRequest,
|
||||
AcpSide, Boxed, StorageMarker, Unboxed,
|
||||
},
|
||||
};
|
||||
|
||||
pub use self::line_reader::LineBufferedRead;
|
||||
pub use self::stdin_reader::spawn_stdin_line_reader;
|
||||
|
||||
#[doc(hidden)]
|
||||
pub use self::common::compact_json;
|
||||
303
crates/codegen/xai-acp-lib/src/line_reader.rs
Normal file
303
crates/codegen/xai-acp-lib/src/line_reader.rs
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
//! Cancel-safe line-buffered [`AsyncRead`] wrapper.
|
||||
//!
|
||||
//! `agent-client-protocol` v0.6's `handle_io` uses `select_biased!` with
|
||||
//! `BufReader::read_line`. `read_line` is **not** cancel-safe: it internally
|
||||
//! calls `consume()` on partial reads, so dropping the future mid-read loses
|
||||
//! bytes and corrupts the stream.
|
||||
//!
|
||||
//! [`LineBufferedRead`] works around this by pre-reading complete `\n`-delimited
|
||||
//! lines on a dedicated task and serving them through a channel. The `poll_read`
|
||||
//! implementation only returns `Pending` *between* lines (when no buffered data
|
||||
//! remains), so ACP's `BufReader::read_line` always finds `\n` without
|
||||
//! suspending, and can never be cancelled mid-read by `select_biased!`.
|
||||
|
||||
use std::{
|
||||
io,
|
||||
pin::Pin,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
|
||||
use futures::{
|
||||
AsyncBufRead, AsyncBufReadExt as _, AsyncRead, SinkExt as _, StreamExt as _, channel::mpsc,
|
||||
io::BufReader,
|
||||
};
|
||||
|
||||
/// Maximum size of a single NDJSON line (64 MiB).
|
||||
///
|
||||
/// Prevents unbounded memory growth if a peer sends data without newlines.
|
||||
/// 64 MiB accommodates the largest legitimate ACP messages (e.g. a
|
||||
/// multi-megabyte file read response after JSON string escaping).
|
||||
const MAX_LINE_SIZE: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// An [`AsyncRead`] that only yields complete `\n`-delimited lines.
|
||||
///
|
||||
/// Internally, a background task reads lines from the wrapped reader and sends
|
||||
/// them through a channel. [`poll_read`](AsyncRead::poll_read) serves bytes
|
||||
/// from the current line buffer and only returns `Poll::Pending` when no
|
||||
/// buffered bytes remain (i.e. between lines). This guarantees that a consumer
|
||||
/// calling `BufReader::read_line` on this reader will always complete without
|
||||
/// intermediate `Pending` states, making it safe to use inside `select!`.
|
||||
pub struct LineBufferedRead {
|
||||
/// Buffered bytes from the current line being served.
|
||||
buf: Vec<u8>,
|
||||
/// Read cursor within `buf`.
|
||||
pos: usize,
|
||||
/// Receives complete lines (or an IO error) from the reader task.
|
||||
rx: mpsc::Receiver<io::Result<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl LineBufferedRead {
|
||||
/// Wrap an `AsyncRead` source, spawning the reader task via
|
||||
/// [`tokio::task::spawn_local`].
|
||||
pub fn spawn_local(source: impl AsyncRead + Unpin + 'static) -> Self {
|
||||
Self::new(source, |fut| {
|
||||
tokio::task::spawn_local(fut);
|
||||
})
|
||||
}
|
||||
|
||||
/// Wrap an `AsyncRead` source with cancel-safe line buffering.
|
||||
///
|
||||
/// A background task is spawned (via `spawn`) that reads `\n`-delimited
|
||||
/// lines from `source` and feeds them into the returned reader.
|
||||
pub fn new(
|
||||
source: impl AsyncRead + Unpin + 'static,
|
||||
spawn: impl FnOnce(futures::future::LocalBoxFuture<'static, ()>),
|
||||
) -> Self {
|
||||
let (mut tx, rx) = mpsc::channel(64);
|
||||
|
||||
spawn(Box::pin(async move {
|
||||
let mut reader = BufReader::new(source);
|
||||
let mut line = Vec::new();
|
||||
loop {
|
||||
match read_line_capped(&mut reader, &mut line).await {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
if tx.send(Ok(line.split_off(0))).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(Err(e)).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
Self {
|
||||
buf: Vec::new(),
|
||||
pos: 0,
|
||||
rx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for LineBufferedRead {
|
||||
fn poll_read(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut [u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
let this = self.get_mut();
|
||||
|
||||
// Serve remaining bytes from the current line.
|
||||
if this.pos < this.buf.len() {
|
||||
let avail = this.buf.len() - this.pos;
|
||||
let n = avail.min(buf.len());
|
||||
buf[..n].copy_from_slice(&this.buf[this.pos..this.pos + n]);
|
||||
this.pos += n;
|
||||
if this.pos >= this.buf.len() {
|
||||
this.buf.clear();
|
||||
this.pos = 0;
|
||||
}
|
||||
return Poll::Ready(Ok(n));
|
||||
}
|
||||
|
||||
// No buffered data — try to receive the next complete line.
|
||||
match this.rx.poll_next_unpin(cx) {
|
||||
Poll::Ready(Some(Ok(line))) => {
|
||||
let n = line.len().min(buf.len());
|
||||
buf[..n].copy_from_slice(&line[..n]);
|
||||
if n < line.len() {
|
||||
// Stash the remainder for subsequent poll_read calls.
|
||||
this.buf = line;
|
||||
this.pos = n;
|
||||
}
|
||||
Poll::Ready(Ok(n))
|
||||
}
|
||||
Poll::Ready(Some(Err(e))) => Poll::Ready(Err(e)),
|
||||
Poll::Ready(None) => Poll::Ready(Ok(0)), // EOF
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a single `\n`-delimited line into `buf`, capped at [`MAX_LINE_SIZE`].
|
||||
///
|
||||
/// Unlike `read_line`, this checks the accumulated size after each internal
|
||||
/// buffer fill, so memory usage stays bounded even if the peer never sends
|
||||
/// a newline.
|
||||
async fn read_line_capped(
|
||||
reader: &mut (impl AsyncBufRead + Unpin),
|
||||
buf: &mut Vec<u8>,
|
||||
) -> io::Result<usize> {
|
||||
buf.clear();
|
||||
loop {
|
||||
let (consumed, done) = {
|
||||
let available = reader.fill_buf().await?;
|
||||
if available.is_empty() {
|
||||
return Ok(buf.len()); // EOF
|
||||
}
|
||||
match available.iter().position(|&b| b == b'\n') {
|
||||
Some(pos) => {
|
||||
buf.extend_from_slice(&available[..=pos]);
|
||||
(pos + 1, true)
|
||||
}
|
||||
None => {
|
||||
buf.extend_from_slice(available);
|
||||
(available.len(), false)
|
||||
}
|
||||
}
|
||||
};
|
||||
reader.consume_unpin(consumed);
|
||||
if buf.len() > MAX_LINE_SIZE {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"ACP message exceeds {} byte limit ({} bytes read)",
|
||||
MAX_LINE_SIZE,
|
||||
buf.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
if done {
|
||||
return Ok(buf.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use futures::{AsyncReadExt as _, io::Cursor};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Helper: run a test inside a tokio LocalSet so spawn_local works.
|
||||
fn run<F: Future<Output = ()>>(f: F) {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(async {
|
||||
tokio::task::LocalSet::new().run_until(f).await;
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_line() {
|
||||
run(async {
|
||||
let source = Cursor::new(b"hello world\n");
|
||||
let mut reader = LineBufferedRead::spawn_local(source);
|
||||
let mut buf = Vec::new();
|
||||
reader.read_to_end(&mut buf).await.unwrap();
|
||||
assert_eq!(buf, b"hello world\n");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_lines() {
|
||||
run(async {
|
||||
let source = Cursor::new(b"line1\nline2\nline3\n");
|
||||
let mut reader = LineBufferedRead::spawn_local(source);
|
||||
let mut buf = Vec::new();
|
||||
reader.read_to_end(&mut buf).await.unwrap();
|
||||
assert_eq!(buf, b"line1\nline2\nline3\n");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eof_with_partial_line() {
|
||||
run(async {
|
||||
let source = Cursor::new(b"complete\nno trailing newline");
|
||||
let mut reader = LineBufferedRead::spawn_local(source);
|
||||
let mut buf = Vec::new();
|
||||
reader.read_to_end(&mut buf).await.unwrap();
|
||||
assert_eq!(buf, b"complete\nno trailing newline");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input() {
|
||||
run(async {
|
||||
let source = Cursor::new(b"");
|
||||
let mut reader = LineBufferedRead::spawn_local(source);
|
||||
let mut buf = Vec::new();
|
||||
reader.read_to_end(&mut buf).await.unwrap();
|
||||
assert!(buf.is_empty());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_line_within_limit() {
|
||||
run(async {
|
||||
// A line larger than BufReader's 8KB buffer but well under 64 MiB.
|
||||
let mut data = vec![b'x'; 100_000];
|
||||
data.push(b'\n');
|
||||
let source = Cursor::new(data.clone());
|
||||
let mut reader = LineBufferedRead::spawn_local(source);
|
||||
let mut buf = Vec::new();
|
||||
reader.read_to_end(&mut buf).await.unwrap();
|
||||
assert_eq!(buf, data);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_line_capped_rejects_oversized() {
|
||||
// Test the capped reader directly with a small override isn't
|
||||
// practical (MAX_LINE_SIZE is const), so test via the real limit.
|
||||
// Just verify the function works for normal input.
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(async {
|
||||
let data = b"normal line\n";
|
||||
let mut reader = BufReader::new(Cursor::new(&data[..]));
|
||||
let mut buf = Vec::new();
|
||||
let n = read_line_capped(&mut reader, &mut buf).await.unwrap();
|
||||
assert_eq!(n, 12);
|
||||
assert_eq!(buf, b"normal line\n");
|
||||
|
||||
// EOF returns 0
|
||||
buf.clear();
|
||||
let n = read_line_capped(&mut reader, &mut buf).await.unwrap();
|
||||
assert_eq!(n, 0);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_read_buffer() {
|
||||
run(async {
|
||||
// Verify poll_read correctly serves a line across multiple small reads.
|
||||
let source = Cursor::new(b"abcdef\n");
|
||||
let mut reader = LineBufferedRead::spawn_local(source);
|
||||
let mut small_buf = [0u8; 3];
|
||||
|
||||
// First read: "abc"
|
||||
let n = reader.read(&mut small_buf).await.unwrap();
|
||||
assert_eq!(&small_buf[..n], b"abc");
|
||||
|
||||
// Second read: "def"
|
||||
let n = reader.read(&mut small_buf).await.unwrap();
|
||||
assert_eq!(&small_buf[..n], b"def");
|
||||
|
||||
// Third read: "\n"
|
||||
let n = reader.read(&mut small_buf).await.unwrap();
|
||||
assert_eq!(&small_buf[..n], b"\n");
|
||||
|
||||
// EOF
|
||||
let n = reader.read(&mut small_buf).await.unwrap();
|
||||
assert_eq!(n, 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
634
crates/codegen/xai-acp-lib/src/message.rs
Normal file
634
crates/codegen/xai-acp-lib/src/message.rs
Normal file
|
|
@ -0,0 +1,634 @@
|
|||
use std::{borrow::Borrow, fmt, ops::Deref};
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
use derive_more::From;
|
||||
use serde::{Deserialize, Serialize, ser::SerializeStruct};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::common::AcpResult;
|
||||
|
||||
pub use self::{
|
||||
agent::{AcpAgentMessage, AcpAgentMessageBox, AcpAgentMessageGeneric},
|
||||
client::{AcpClientMessage, AcpClientMessageBox, AcpClientMessageGeneric},
|
||||
};
|
||||
|
||||
/// Marker trait representing one side of the ACP connection.
|
||||
pub trait AcpSide {
|
||||
/// What does this side receive.
|
||||
type InMessage: AcpMethod + fmt::Debug;
|
||||
/// What does this side send.
|
||||
type OutMessage: AcpMethod + fmt::Debug;
|
||||
/// Marker type for the other side.
|
||||
type OtherSide: AcpSide;
|
||||
/// Display name for this side.
|
||||
const NAME: &'static str;
|
||||
}
|
||||
|
||||
/// Marker type representing the agent's view of the ACP connection (as one side of that connection).
|
||||
impl AcpSide for acp::AgentSide {
|
||||
type InMessage = AcpAgentMessage; // inbound messages = messages meant *for* the agent
|
||||
type OutMessage = AcpClientMessage; // outbound messages = messages meant *for* the client
|
||||
type OtherSide = acp::ClientSide;
|
||||
const NAME: &'static str = "agent";
|
||||
}
|
||||
|
||||
/// Marker type representing the agent's view of the ACP connection (as one side of that connection).
|
||||
impl AcpSide for acp::ClientSide {
|
||||
type InMessage = AcpClientMessage; // inbound messages = messages meant *for* the client
|
||||
type OutMessage = AcpAgentMessage; // outbound messages = messages meant *for* the agent
|
||||
type OtherSide = acp::AgentSide;
|
||||
const NAME: &'static str = "client";
|
||||
}
|
||||
|
||||
/// Extends each request/response type pair with the side marker type and schema method name.
|
||||
pub trait AcpMethod {
|
||||
fn method_name(&self) -> &'static str;
|
||||
}
|
||||
|
||||
/// Connect together ACP request and response types for each rpc method.
|
||||
pub trait AcpRequest: Clone + fmt::Debug + Serialize + AcpMethod {
|
||||
type Response: Clone + fmt::Debug + Serialize;
|
||||
}
|
||||
|
||||
/// Contains an ACP request and a oneshot channel where the response of a matching type can be sent.
|
||||
pub struct AcpArgsGeneric<T: AcpRequest, S: StorageMarker> {
|
||||
pub request: S::Type<T>,
|
||||
pub response_tx: oneshot::Sender<AcpResult<T::Response>>,
|
||||
}
|
||||
|
||||
impl<T: AcpRequest, S: StorageMarker> Deref for AcpArgsGeneric<T, S> {
|
||||
type Target = T;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.request.borrow()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: AcpRequest, S: StorageMarker> fmt::Debug for AcpArgsGeneric<T, S> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "{:?}", self.request.borrow())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: AcpRequest, S: StorageMarker> AcpMethod for AcpArgsGeneric<T, S> {
|
||||
fn method_name(&self) -> &'static str {
|
||||
self.request.borrow().method_name()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(type_alias_bounds)]
|
||||
pub type AcpArgs<T: AcpRequest> = AcpArgsGeneric<T, Unboxed>;
|
||||
#[allow(type_alias_bounds)]
|
||||
pub type AcpArgsBox<T: AcpRequest> = AcpArgsGeneric<T, Boxed>;
|
||||
|
||||
impl<T: AcpRequest> AcpArgs<T> {
|
||||
pub fn boxed(self) -> AcpArgsBox<T> {
|
||||
AcpArgsBox {
|
||||
request: Box::new(self.request),
|
||||
response_tx: self.response_tx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! acp_define_request_response {
|
||||
($request:ty, $response:ty, $method:expr $(,)?) => {
|
||||
impl AcpRequest for $request {
|
||||
type Response = $response;
|
||||
}
|
||||
|
||||
impl AcpMethod for $request {
|
||||
fn method_name(&self) -> &'static str {
|
||||
$method
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
acp_define_request_response!(acp::ExtRequest, acp::ExtResponse, "ext_method");
|
||||
acp_define_request_response!(acp::ExtNotification, (), "ext_notification");
|
||||
|
||||
pub trait StorageMarker: fmt::Debug + Clone + Copy {
|
||||
type Type<T>: Borrow<T> + From<T>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Unboxed;
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Boxed;
|
||||
|
||||
impl StorageMarker for Unboxed {
|
||||
type Type<T> = T;
|
||||
}
|
||||
|
||||
impl StorageMarker for Boxed {
|
||||
type Type<T> = Box<T>;
|
||||
}
|
||||
|
||||
mod client {
|
||||
use futures::{FutureExt as _, future::LocalBoxFuture};
|
||||
|
||||
use super::*;
|
||||
|
||||
acp_define_request_response!(
|
||||
acp::RequestPermissionRequest,
|
||||
acp::RequestPermissionResponse,
|
||||
acp::CLIENT_METHOD_NAMES.session_request_permission,
|
||||
);
|
||||
acp_define_request_response!(
|
||||
acp::ReadTextFileRequest,
|
||||
acp::ReadTextFileResponse,
|
||||
acp::CLIENT_METHOD_NAMES.fs_read_text_file,
|
||||
);
|
||||
acp_define_request_response!(
|
||||
acp::WriteTextFileRequest,
|
||||
acp::WriteTextFileResponse,
|
||||
acp::CLIENT_METHOD_NAMES.fs_write_text_file,
|
||||
);
|
||||
acp_define_request_response!(
|
||||
acp::SessionNotification,
|
||||
(),
|
||||
acp::CLIENT_METHOD_NAMES.session_update,
|
||||
);
|
||||
acp_define_request_response!(
|
||||
acp::CreateTerminalRequest,
|
||||
acp::CreateTerminalResponse,
|
||||
acp::CLIENT_METHOD_NAMES.terminal_create,
|
||||
);
|
||||
acp_define_request_response!(
|
||||
acp::TerminalOutputRequest,
|
||||
acp::TerminalOutputResponse,
|
||||
acp::CLIENT_METHOD_NAMES.terminal_output,
|
||||
);
|
||||
acp_define_request_response!(
|
||||
acp::ReleaseTerminalRequest,
|
||||
acp::ReleaseTerminalResponse,
|
||||
acp::CLIENT_METHOD_NAMES.terminal_release,
|
||||
);
|
||||
acp_define_request_response!(
|
||||
acp::WaitForTerminalExitRequest,
|
||||
acp::WaitForTerminalExitResponse,
|
||||
acp::CLIENT_METHOD_NAMES.terminal_wait_for_exit,
|
||||
);
|
||||
acp_define_request_response!(
|
||||
acp::KillTerminalRequest,
|
||||
acp::KillTerminalResponse,
|
||||
acp::CLIENT_METHOD_NAMES.terminal_kill,
|
||||
);
|
||||
|
||||
/// ACP messages meant *for* the client.
|
||||
#[derive(Debug, From)]
|
||||
pub enum AcpClientMessageGeneric<S: StorageMarker> {
|
||||
RequestPermission(AcpArgsGeneric<acp::RequestPermissionRequest, S>),
|
||||
ReadTextFile(AcpArgsGeneric<acp::ReadTextFileRequest, S>),
|
||||
WriteTextFile(AcpArgsGeneric<acp::WriteTextFileRequest, S>),
|
||||
SessionNotification(AcpArgsGeneric<acp::SessionNotification, S>),
|
||||
CreateTerminal(AcpArgsGeneric<acp::CreateTerminalRequest, S>),
|
||||
TerminalOutput(AcpArgsGeneric<acp::TerminalOutputRequest, S>),
|
||||
ReleaseTerminal(AcpArgsGeneric<acp::ReleaseTerminalRequest, S>),
|
||||
WaitForTerminalExit(AcpArgsGeneric<acp::WaitForTerminalExitRequest, S>),
|
||||
KillTerminalCommand(AcpArgsGeneric<acp::KillTerminalRequest, S>),
|
||||
ExtMethod(AcpArgsGeneric<acp::ExtRequest, S>),
|
||||
ExtNotification(AcpArgsGeneric<acp::ExtNotification, S>),
|
||||
}
|
||||
|
||||
#[allow(type_alias_bounds)]
|
||||
pub type AcpClientMessage = AcpClientMessageGeneric<Unboxed>;
|
||||
#[allow(type_alias_bounds)]
|
||||
pub type AcpClientMessageBox = AcpClientMessageGeneric<Boxed>;
|
||||
|
||||
impl<S: StorageMarker> AcpMethod for AcpClientMessageGeneric<S> {
|
||||
fn method_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::RequestPermission(a) => a.method_name(),
|
||||
Self::ReadTextFile(a) => a.method_name(),
|
||||
Self::WriteTextFile(a) => a.method_name(),
|
||||
Self::SessionNotification(a) => a.method_name(),
|
||||
Self::CreateTerminal(a) => a.method_name(),
|
||||
Self::TerminalOutput(a) => a.method_name(),
|
||||
Self::ReleaseTerminal(a) => a.method_name(),
|
||||
Self::WaitForTerminalExit(a) => a.method_name(),
|
||||
Self::KillTerminalCommand(a) => a.method_name(),
|
||||
Self::ExtMethod(a) => a.method_name(),
|
||||
Self::ExtNotification(a) => a.method_name(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AcpClientMessage {
|
||||
pub fn boxed(self) -> AcpClientMessageBox {
|
||||
match self {
|
||||
Self::RequestPermission(args) => {
|
||||
AcpClientMessageBox::RequestPermission(args.boxed())
|
||||
}
|
||||
Self::ReadTextFile(args) => AcpClientMessageBox::ReadTextFile(args.boxed()),
|
||||
Self::WriteTextFile(args) => AcpClientMessageBox::WriteTextFile(args.boxed()),
|
||||
Self::SessionNotification(args) => {
|
||||
AcpClientMessageBox::SessionNotification(args.boxed())
|
||||
}
|
||||
Self::CreateTerminal(args) => AcpClientMessageBox::CreateTerminal(args.boxed()),
|
||||
Self::TerminalOutput(args) => AcpClientMessageBox::TerminalOutput(args.boxed()),
|
||||
Self::ReleaseTerminal(args) => AcpClientMessageBox::ReleaseTerminal(args.boxed()),
|
||||
Self::WaitForTerminalExit(args) => {
|
||||
AcpClientMessageBox::WaitForTerminalExit(args.boxed())
|
||||
}
|
||||
Self::KillTerminalCommand(args) => {
|
||||
AcpClientMessageBox::KillTerminalCommand(args.boxed())
|
||||
}
|
||||
Self::ExtMethod(args) => AcpClientMessageBox::ExtMethod(args.boxed()),
|
||||
Self::ExtNotification(args) => AcpClientMessageBox::ExtNotification(args.boxed()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn route_to_client(
|
||||
self,
|
||||
client: impl acp::Client + 'static, // note: acp::Client is auto-implemented for Rc/Arc
|
||||
spawn: impl Fn(LocalBoxFuture<'static, ()>) + 'static,
|
||||
) {
|
||||
match self {
|
||||
AcpClientMessage::RequestPermission(args) => spawn(
|
||||
async move {
|
||||
_ = args
|
||||
.response_tx
|
||||
.send(client.request_permission(args.request).await)
|
||||
.ok();
|
||||
}
|
||||
.boxed_local(),
|
||||
),
|
||||
AcpClientMessage::ReadTextFile(args) => spawn(
|
||||
async move {
|
||||
_ = args
|
||||
.response_tx
|
||||
.send(client.read_text_file(args.request).await)
|
||||
.ok();
|
||||
}
|
||||
.boxed_local(),
|
||||
),
|
||||
AcpClientMessage::WriteTextFile(args) => spawn(
|
||||
async move {
|
||||
_ = args
|
||||
.response_tx
|
||||
.send(client.write_text_file(args.request).await)
|
||||
.ok();
|
||||
}
|
||||
.boxed_local(),
|
||||
),
|
||||
AcpClientMessage::SessionNotification(args) => spawn(
|
||||
async move {
|
||||
_ = args
|
||||
.response_tx
|
||||
.send(client.session_notification(args.request).await)
|
||||
.ok();
|
||||
}
|
||||
.boxed_local(),
|
||||
),
|
||||
AcpClientMessage::CreateTerminal(args) => spawn(
|
||||
async move {
|
||||
_ = args
|
||||
.response_tx
|
||||
.send(client.create_terminal(args.request).await)
|
||||
.ok();
|
||||
}
|
||||
.boxed_local(),
|
||||
),
|
||||
AcpClientMessage::TerminalOutput(args) => spawn(
|
||||
async move {
|
||||
_ = args
|
||||
.response_tx
|
||||
.send(client.terminal_output(args.request).await)
|
||||
.ok();
|
||||
}
|
||||
.boxed_local(),
|
||||
),
|
||||
AcpClientMessage::ReleaseTerminal(args) => spawn(
|
||||
async move {
|
||||
_ = args
|
||||
.response_tx
|
||||
.send(client.release_terminal(args.request).await)
|
||||
.ok();
|
||||
}
|
||||
.boxed_local(),
|
||||
),
|
||||
AcpClientMessage::WaitForTerminalExit(args) => spawn(
|
||||
async move {
|
||||
_ = args
|
||||
.response_tx
|
||||
.send(client.wait_for_terminal_exit(args.request).await)
|
||||
.ok();
|
||||
}
|
||||
.boxed_local(),
|
||||
),
|
||||
AcpClientMessage::KillTerminalCommand(args) => spawn(
|
||||
async move {
|
||||
_ = args
|
||||
.response_tx
|
||||
.send(client.kill_terminal(args.request).await)
|
||||
.ok();
|
||||
}
|
||||
.boxed_local(),
|
||||
),
|
||||
AcpClientMessage::ExtMethod(args) => spawn(
|
||||
async move {
|
||||
_ = args
|
||||
.response_tx
|
||||
.send(client.ext_method(args.request).await)
|
||||
.ok();
|
||||
}
|
||||
.boxed_local(),
|
||||
),
|
||||
AcpClientMessage::ExtNotification(args) => spawn(
|
||||
async move {
|
||||
_ = args
|
||||
.response_tx
|
||||
.send(client.ext_notification(args.request).await)
|
||||
.ok();
|
||||
}
|
||||
.boxed_local(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod agent {
|
||||
use futures::{FutureExt as _, future::LocalBoxFuture};
|
||||
|
||||
use super::*;
|
||||
|
||||
acp_define_request_response!(
|
||||
acp::InitializeRequest,
|
||||
acp::InitializeResponse,
|
||||
acp::AGENT_METHOD_NAMES.initialize,
|
||||
);
|
||||
acp_define_request_response!(
|
||||
acp::AuthenticateRequest,
|
||||
acp::AuthenticateResponse,
|
||||
acp::AGENT_METHOD_NAMES.authenticate,
|
||||
);
|
||||
acp_define_request_response!(
|
||||
acp::NewSessionRequest,
|
||||
acp::NewSessionResponse,
|
||||
acp::AGENT_METHOD_NAMES.session_new,
|
||||
);
|
||||
acp_define_request_response!(
|
||||
acp::LoadSessionRequest,
|
||||
acp::LoadSessionResponse,
|
||||
acp::AGENT_METHOD_NAMES.session_load,
|
||||
);
|
||||
acp_define_request_response!(
|
||||
acp::SetSessionModeRequest,
|
||||
acp::SetSessionModeResponse,
|
||||
acp::AGENT_METHOD_NAMES.session_set_mode,
|
||||
);
|
||||
acp_define_request_response!(
|
||||
acp::PromptRequest,
|
||||
acp::PromptResponse,
|
||||
acp::AGENT_METHOD_NAMES.session_prompt,
|
||||
);
|
||||
acp_define_request_response!(
|
||||
acp::CancelNotification,
|
||||
(),
|
||||
acp::AGENT_METHOD_NAMES.session_cancel,
|
||||
);
|
||||
acp_define_request_response!(
|
||||
acp::SetSessionModelRequest,
|
||||
acp::SetSessionModelResponse,
|
||||
acp::AGENT_METHOD_NAMES.session_set_model,
|
||||
);
|
||||
|
||||
/// ACP messages meant *for* the agent.
|
||||
#[derive(Debug, From)]
|
||||
pub enum AcpAgentMessageGeneric<S: StorageMarker> {
|
||||
Initialize(AcpArgsGeneric<acp::InitializeRequest, S>),
|
||||
Authenticate(AcpArgsGeneric<acp::AuthenticateRequest, S>),
|
||||
NewSession(AcpArgsGeneric<acp::NewSessionRequest, S>),
|
||||
LoadSession(AcpArgsGeneric<acp::LoadSessionRequest, S>),
|
||||
SetSessionMode(AcpArgsGeneric<acp::SetSessionModeRequest, S>),
|
||||
Prompt(AcpArgsGeneric<acp::PromptRequest, S>),
|
||||
Cancel(AcpArgsGeneric<acp::CancelNotification, S>),
|
||||
ExtMethod(AcpArgsGeneric<acp::ExtRequest, S>),
|
||||
ExtNotification(AcpArgsGeneric<acp::ExtNotification, S>),
|
||||
SetSessionModel(AcpArgsGeneric<acp::SetSessionModelRequest, S>),
|
||||
}
|
||||
|
||||
#[allow(type_alias_bounds)]
|
||||
pub type AcpAgentMessage = AcpAgentMessageGeneric<Unboxed>;
|
||||
#[allow(type_alias_bounds)]
|
||||
pub type AcpAgentMessageBox = AcpAgentMessageGeneric<Boxed>;
|
||||
|
||||
impl<S: StorageMarker> AcpMethod for AcpAgentMessageGeneric<S> {
|
||||
fn method_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Initialize(a) => a.method_name(),
|
||||
Self::Authenticate(a) => a.method_name(),
|
||||
Self::NewSession(a) => a.method_name(),
|
||||
Self::LoadSession(a) => a.method_name(),
|
||||
Self::SetSessionMode(a) => a.method_name(),
|
||||
Self::Prompt(a) => a.method_name(),
|
||||
Self::Cancel(a) => a.method_name(),
|
||||
Self::ExtMethod(a) => a.method_name(),
|
||||
Self::ExtNotification(a) => a.method_name(),
|
||||
Self::SetSessionModel(a) => a.method_name(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: StorageMarker> Serialize for AcpAgentMessageGeneric<S> {
|
||||
fn serialize<Ser>(&self, serializer: Ser) -> Result<Ser::Ok, Ser::Error>
|
||||
where
|
||||
Ser: serde::Serializer,
|
||||
{
|
||||
let mut state = serializer.serialize_struct("AcpAgentMessage", 2)?;
|
||||
state.serialize_field("method_name", self.method_name())?;
|
||||
match self {
|
||||
Self::Initialize(args) => {
|
||||
state.serialize_field("request", args.request.borrow())?
|
||||
}
|
||||
Self::Authenticate(args) => {
|
||||
state.serialize_field("request", args.request.borrow())?
|
||||
}
|
||||
Self::NewSession(args) => {
|
||||
state.serialize_field("request", args.request.borrow())?
|
||||
}
|
||||
Self::LoadSession(args) => {
|
||||
state.serialize_field("request", args.request.borrow())?
|
||||
}
|
||||
Self::SetSessionMode(args) => {
|
||||
state.serialize_field("request", args.request.borrow())?
|
||||
}
|
||||
Self::Prompt(args) => state.serialize_field("request", args.request.borrow())?,
|
||||
Self::Cancel(args) => state.serialize_field("request", args.request.borrow())?,
|
||||
Self::ExtMethod(args) => state.serialize_field("request", args.request.borrow())?,
|
||||
Self::ExtNotification(args) => {
|
||||
state.serialize_field("request", args.request.borrow())?
|
||||
}
|
||||
Self::SetSessionModel(args) => {
|
||||
state.serialize_field("request", args.request.borrow())?
|
||||
}
|
||||
}
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for AcpAgentMessage {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
struct RawMessage {
|
||||
method_name: String,
|
||||
request: serde_json::Value,
|
||||
}
|
||||
|
||||
let raw = RawMessage::deserialize(deserializer)?;
|
||||
let method = raw.method_name.as_str();
|
||||
|
||||
macro_rules! parse {
|
||||
($variant:ident) => {{
|
||||
let (response_tx, _) = oneshot::channel();
|
||||
Ok(Self::$variant(AcpArgs {
|
||||
request: serde_json::from_value(raw.request)
|
||||
.map_err(serde::de::Error::custom)?,
|
||||
response_tx,
|
||||
}))
|
||||
}};
|
||||
}
|
||||
|
||||
if method == acp::AGENT_METHOD_NAMES.initialize {
|
||||
parse!(Initialize)
|
||||
} else if method == acp::AGENT_METHOD_NAMES.authenticate {
|
||||
parse!(Authenticate)
|
||||
} else if method == acp::AGENT_METHOD_NAMES.session_new {
|
||||
parse!(NewSession)
|
||||
} else if method == acp::AGENT_METHOD_NAMES.session_load {
|
||||
parse!(LoadSession)
|
||||
} else if method == acp::AGENT_METHOD_NAMES.session_set_mode {
|
||||
parse!(SetSessionMode)
|
||||
} else if method == acp::AGENT_METHOD_NAMES.session_prompt {
|
||||
parse!(Prompt)
|
||||
} else if method == acp::AGENT_METHOD_NAMES.session_cancel {
|
||||
parse!(Cancel)
|
||||
} else if method == acp::AGENT_METHOD_NAMES.session_set_model {
|
||||
parse!(SetSessionModel)
|
||||
} else if method == "ext_method" {
|
||||
parse!(ExtMethod)
|
||||
} else if method == "ext_notification" {
|
||||
parse!(ExtNotification)
|
||||
} else {
|
||||
Err(serde::de::Error::custom(format!(
|
||||
"Unknown method name: {method}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AcpAgentMessage {
|
||||
pub fn boxed(self) -> AcpAgentMessageBox {
|
||||
match self {
|
||||
Self::Initialize(args) => AcpAgentMessageBox::Initialize(args.boxed()),
|
||||
Self::Authenticate(args) => AcpAgentMessageBox::Authenticate(args.boxed()),
|
||||
Self::NewSession(args) => AcpAgentMessageBox::NewSession(args.boxed()),
|
||||
Self::LoadSession(args) => AcpAgentMessageBox::LoadSession(args.boxed()),
|
||||
Self::SetSessionMode(args) => AcpAgentMessageBox::SetSessionMode(args.boxed()),
|
||||
Self::Prompt(args) => AcpAgentMessageBox::Prompt(args.boxed()),
|
||||
Self::Cancel(args) => AcpAgentMessageBox::Cancel(args.boxed()),
|
||||
Self::ExtMethod(args) => AcpAgentMessageBox::ExtMethod(args.boxed()),
|
||||
Self::ExtNotification(args) => AcpAgentMessageBox::ExtNotification(args.boxed()),
|
||||
Self::SetSessionModel(args) => AcpAgentMessageBox::SetSessionModel(args.boxed()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn route_to_agent(
|
||||
self,
|
||||
agent: impl acp::Agent + 'static, // note: acp::Agent is auto-implemented for Rc/Arc
|
||||
spawn: impl Fn(LocalBoxFuture<'static, ()>) + 'static,
|
||||
) {
|
||||
match self {
|
||||
AcpAgentMessage::Initialize(args) => spawn(
|
||||
async move {
|
||||
_ = args
|
||||
.response_tx
|
||||
.send(agent.initialize(args.request).await)
|
||||
.ok();
|
||||
}
|
||||
.boxed_local(),
|
||||
),
|
||||
AcpAgentMessage::Authenticate(args) => spawn(
|
||||
async move {
|
||||
_ = args
|
||||
.response_tx
|
||||
.send(agent.authenticate(args.request).await)
|
||||
.ok();
|
||||
}
|
||||
.boxed_local(),
|
||||
),
|
||||
AcpAgentMessage::NewSession(args) => spawn(
|
||||
async move {
|
||||
_ = args
|
||||
.response_tx
|
||||
.send(agent.new_session(args.request).await)
|
||||
.ok();
|
||||
}
|
||||
.boxed_local(),
|
||||
),
|
||||
AcpAgentMessage::LoadSession(args) => spawn(
|
||||
async move {
|
||||
_ = args
|
||||
.response_tx
|
||||
.send(agent.load_session(args.request).await)
|
||||
.ok();
|
||||
}
|
||||
.boxed_local(),
|
||||
),
|
||||
AcpAgentMessage::SetSessionMode(args) => spawn(
|
||||
async move {
|
||||
_ = args
|
||||
.response_tx
|
||||
.send(agent.set_session_mode(args.request).await)
|
||||
.ok();
|
||||
}
|
||||
.boxed_local(),
|
||||
),
|
||||
AcpAgentMessage::Prompt(args) => spawn(
|
||||
async move {
|
||||
_ = args.response_tx.send(agent.prompt(args.request).await).ok();
|
||||
}
|
||||
.boxed_local(),
|
||||
),
|
||||
AcpAgentMessage::Cancel(args) => spawn(
|
||||
async move {
|
||||
_ = args.response_tx.send(agent.cancel(args.request).await).ok();
|
||||
}
|
||||
.boxed_local(),
|
||||
),
|
||||
AcpAgentMessage::ExtMethod(args) => spawn(
|
||||
async move {
|
||||
_ = args
|
||||
.response_tx
|
||||
.send(agent.ext_method(args.request).await)
|
||||
.ok();
|
||||
}
|
||||
.boxed_local(),
|
||||
),
|
||||
AcpAgentMessage::ExtNotification(args) => spawn(
|
||||
async move {
|
||||
_ = args
|
||||
.response_tx
|
||||
.send(agent.ext_notification(args.request).await)
|
||||
.ok();
|
||||
}
|
||||
.boxed_local(),
|
||||
),
|
||||
AcpAgentMessage::SetSessionModel(args) => spawn(
|
||||
async move {
|
||||
_ = args
|
||||
.response_tx
|
||||
.send(agent.set_session_model(args.request).await)
|
||||
.ok();
|
||||
}
|
||||
.boxed_local(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
181
crates/codegen/xai-acp-lib/src/normalize.rs
Normal file
181
crates/codegen/xai-acp-lib/src/normalize.rs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
//! Foundation escaped-slash normalization for inbound ACP stdin lines — the
|
||||
//! crate's second `agent-client-protocol` v0.6 wire workaround, alongside
|
||||
//! [`LineBufferedRead`](crate::LineBufferedRead).
|
||||
//!
|
||||
//! [`spawn_stdin_line_reader`](crate::spawn_stdin_line_reader) feeds every
|
||||
//! line through [`normalize_json_line`]; unhooking that one call site removes
|
||||
//! the workaround once the upstream envelope parses `method` as an
|
||||
//! owned/`Cow` string.
|
||||
//!
|
||||
//! Scope: only process-stdin ingress is normalized. Clients that connect
|
||||
//! directly to the leader socket bypass this module — fine today, those are
|
||||
//! first-party clients whose encoders never emit `\/`.
|
||||
//!
|
||||
//! Downstream dependency: the leader bridge's replay sniff
|
||||
//! (`xai-grok-pager-bin/src/main.rs`, the `trimmed.contains("\"session/new\"")`
|
||||
//! checks) matches escaped Foundation input only because this normalization
|
||||
//! runs upstream of it.
|
||||
|
||||
/// Foundation (Xcode) escapes `/` as `\/` by default, and the pinned
|
||||
/// `agent-client-protocol` 0.6 envelope parses `method` as a borrowed `&str`
|
||||
/// ([`RawIncomingMessage`](agent_client_protocol::RawIncomingMessage)), so any
|
||||
/// escape inside `method` fails the whole envelope parse and the line is
|
||||
/// silently dropped. Re-serializing through `serde_json` — which never emits
|
||||
/// `\/` — makes the method borrowable again.
|
||||
///
|
||||
/// Rewrites touch only lines the crate would otherwise drop: the two-byte `\/`
|
||||
/// scan is a cheap prefilter (serde_json / `JSON.stringify` never emit it),
|
||||
/// and a line that then parses as the real pinned envelope — e.g. a
|
||||
/// clean-method prompt whose params contain `s/\//_/g` — passes through
|
||||
/// byte-identical, so healthy clients are untouched by construction. Tradeoff:
|
||||
/// a hypothetical `\u002F`-escaped method is not normalized (no known encoder
|
||||
/// emits that, and `\u` can't be the prefilter — JS legitimately emits
|
||||
/// `\u2028` and surrogate pairs in text).
|
||||
///
|
||||
/// Any line that fails both parses passes through byte-identical —
|
||||
/// deliberately: the acp crate keeps ownership of garbage handling.
|
||||
pub(crate) fn normalize_json_line(line: Vec<u8>) -> Vec<u8> {
|
||||
if !line.windows(2).any(|w| w == br"\/") {
|
||||
return line;
|
||||
}
|
||||
// Same type + bytes the acp crate will parse (trailing terminator is JSON
|
||||
// whitespace): if it accepts the line, forward it byte-identical.
|
||||
if serde_json::from_slice::<agent_client_protocol::RawIncomingMessage>(&line).is_ok() {
|
||||
return line;
|
||||
}
|
||||
let body_len = line
|
||||
.iter()
|
||||
.rposition(|&b| b != b'\n' && b != b'\r')
|
||||
.map_or(0, |pos| pos + 1);
|
||||
let Ok(value) = serde_json::from_slice::<serde_json::Value>(&line[..body_len]) else {
|
||||
// Exactly the line class the acp 0.6 envelope will then drop silently.
|
||||
tracing::debug!(
|
||||
len = line.len(),
|
||||
"unparseable escaped-slash stdin line passed through; acp may drop it"
|
||||
);
|
||||
return line;
|
||||
};
|
||||
let Ok(mut normalized) = serde_json::to_vec(&value) else {
|
||||
return line;
|
||||
};
|
||||
normalized.extend_from_slice(&line[body_len..]);
|
||||
tracing::debug!(
|
||||
len = line.len(),
|
||||
normalized_len = normalized.len(),
|
||||
"normalized escaped-slash line for the acp 0.6 envelope"
|
||||
);
|
||||
normalized
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use agent_client_protocol::RawIncomingMessage;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn escaped_slash_method_accepted_by_upstream_cow_envelope() {
|
||||
// acp 0.10.4+ parses `method` as Cow<str>, so Foundation-style
|
||||
// `session\/prompt` is accepted without our re-serialization rewrite.
|
||||
let raw =
|
||||
br#"{"jsonrpc":"2.0","id":"5DE7EA60-0B0C-4A43-9650-2B72CDF6A44B","method":"session\/prompt","params":{}}"#;
|
||||
let mut line = raw.to_vec();
|
||||
line.push(b'\n');
|
||||
assert!(serde_json::from_slice::<RawIncomingMessage>(raw).is_ok());
|
||||
|
||||
let normalized = normalize_json_line(line.clone());
|
||||
// Early-return path: envelope-acceptable lines pass through byte-identical.
|
||||
assert_eq!(normalized, line);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_with_escaped_slash_passes_through_byte_identical() {
|
||||
let line = b"not json \\/ at all\n".to_vec();
|
||||
assert_eq!(normalize_json_line(line.clone()), line);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_without_backslash_passes_through_untouched() {
|
||||
let expected = br#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{}}"#.to_vec();
|
||||
let line = expected.clone();
|
||||
let ptr = line.as_ptr();
|
||||
|
||||
let normalized = normalize_json_line(line);
|
||||
|
||||
assert_eq!(normalized, expected);
|
||||
// Same allocation: the fast path never parsed or re-serialized.
|
||||
assert_eq!(normalized.as_ptr(), ptr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_escapes_without_escaped_slash_pass_through_untouched() {
|
||||
let raw =
|
||||
br#"{"jsonrpc":"2.0","id":1,"method":"session/prompt","params":{"text":"a\nb \"q\" c\\d"}}"#;
|
||||
let expected = raw.to_vec();
|
||||
let line = expected.clone();
|
||||
let ptr = line.as_ptr();
|
||||
|
||||
let normalized = normalize_json_line(line);
|
||||
|
||||
assert_eq!(normalized, expected);
|
||||
// Same allocation: `\n`/`\"`/`\\` escapes alone never trip the rewrite.
|
||||
assert_eq!(normalized.as_ptr(), ptr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_slash_in_params_with_clean_method_passes_through_byte_identical() {
|
||||
// serde_json wire form of a prompt containing `s/\//_/g`: the `\\/`
|
||||
// bytes trip the `\/` prefilter, but the envelope parses the line.
|
||||
let raw =
|
||||
br#"{"jsonrpc":"2.0","id":1,"method":"session/prompt","params":{"text":"s/\\//_/g"}}"#;
|
||||
assert!(raw.windows(2).any(|w| w == br"\/"));
|
||||
assert!(serde_json::from_slice::<RawIncomingMessage>(raw).is_ok());
|
||||
let expected = raw.to_vec();
|
||||
let line = expected.clone();
|
||||
let ptr = line.as_ptr();
|
||||
|
||||
let normalized = normalize_json_line(line);
|
||||
|
||||
assert_eq!(normalized, expected);
|
||||
// Same allocation: envelope-acceptable lines are never re-serialized.
|
||||
assert_eq!(normalized.as_ptr(), ptr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_string_escapes_keep_their_semantics() {
|
||||
let raw =
|
||||
br#"{"jsonrpc":"2.0","id":1,"method":"session\/prompt","params":{"text":"a\/b\nc \"q\" d\\e"}}"#;
|
||||
let mut line = raw.to_vec();
|
||||
line.push(b'\n');
|
||||
|
||||
let normalized = normalize_json_line(line);
|
||||
|
||||
let value: serde_json::Value = serde_json::from_slice(&normalized).unwrap();
|
||||
assert_eq!(value["method"], "session/prompt");
|
||||
assert_eq!(value["params"]["text"], "a/b\nc \"q\" d\\e");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crlf_terminator_is_preserved() {
|
||||
let mut line = br#"{"id":2,"method":"session\/new"}"#.to_vec();
|
||||
line.extend_from_slice(b"\r\n");
|
||||
|
||||
let normalized = normalize_json_line(line);
|
||||
|
||||
assert!(normalized.ends_with(b"\r\n"));
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_slice(&normalized[..normalized.len() - 2]).unwrap();
|
||||
assert_eq!(value["method"], "session/new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn final_line_without_newline_gains_no_newline() {
|
||||
let line = br#"{"id":3,"method":"session\/new"}"#.to_vec();
|
||||
|
||||
let normalized = normalize_json_line(line);
|
||||
|
||||
assert_ne!(normalized.last(), Some(&b'\n'));
|
||||
let value: serde_json::Value = serde_json::from_slice(&normalized).unwrap();
|
||||
assert_eq!(value["method"], "session/new");
|
||||
}
|
||||
}
|
||||
216
crates/codegen/xai-acp-lib/src/stdin_reader.rs
Normal file
216
crates/codegen/xai-acp-lib/src/stdin_reader.rs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
//! Dedicated-thread reader for the ACP stdio transport's standard input.
|
||||
//!
|
||||
//! Every ACP client (VS Code extension, grok-desktop, the leader bridge) drives
|
||||
//! the agent over a **persistent, bidirectional** newline-delimited JSON-RPC
|
||||
//! stream on stdio: it writes requests on the child's stdin and reads responses
|
||||
//! on stdout, keeping **stdin open for the whole session**.
|
||||
//!
|
||||
//! # Why not `tokio::io::stdin()`
|
||||
//!
|
||||
//! `tokio::io::stdin()` is not truly asynchronous. Tokio services it with a
|
||||
//! blocking `std::io` read on an internal pool thread, and that read **cannot be
|
||||
//! cancelled**. For interactive / persistent uses the
|
||||
//! [`tokio::io::Stdin`](https://docs.rs/tokio/latest/tokio/io/struct.Stdin.html)
|
||||
//! docs recommend "spawn a thread dedicated to user input and use blocking IO
|
||||
//! directly in that thread". [`spawn_stdin_line_reader`] does exactly that.
|
||||
//!
|
||||
//! # Why the reader takes *exclusive* ownership of stdin (Windows)
|
||||
//!
|
||||
//! `std::io::Stdin` is a process-global handle guarded by a re-entrant mutex
|
||||
//! (the `StdinLock`). A blocking read **holds that lock for the entire duration
|
||||
//! of the read** — and for the persistent stdio transport the reader is almost
|
||||
//! always parked in a read, waiting for the client's next line. If *any other*
|
||||
//! code in the process then calls `std::io::stdin()` (e.g. a stray interactive
|
||||
//! prompt reached only on a particular platform), it blocks on the lock until
|
||||
//! the reader's in-flight read returns — which only happens at **EOF**, i.e.
|
||||
//! when the client closes stdin. For a persistent ACP client that never closes
|
||||
//! stdin mid-session this is a hard hang: the agent freezes part-way through a
|
||||
//! request (observed on **Windows** during `session/new`) and only unblocks when
|
||||
//! the transport is torn down. macOS/Linux don't reach the offending stray read,
|
||||
//! so they were unaffected — but the hazard is real on any platform.
|
||||
//!
|
||||
//! To make the transport robust, on Windows the reader thread takes a **private
|
||||
//! duplicate** of the real stdin handle and then points the process's standard
|
||||
//! input at **`NUL`**. The reader keeps reading the client's bytes through its
|
||||
//! private handle, while every *other* `std::io::stdin()` read in the process
|
||||
//! observes immediate EOF instead of deadlocking on the lock. This mirrors what
|
||||
//! already makes leader mode safe (the agent subprocess is spawned with
|
||||
//! `stdin = NUL`, so its stray reads EOF instantly). Unix keeps reading
|
||||
//! `std::io::stdin()` directly — it has no second stdin reader on these paths
|
||||
//! and the extra FFI/`dup` would add risk for no benefit.
|
||||
//!
|
||||
//! # Escaped-slash normalization (acp 0.6 wire workaround)
|
||||
//!
|
||||
//! Every line is forwarded through `normalize_json_line` — see the
|
||||
//! crate-private `normalize` module for the contract and its scope.
|
||||
|
||||
use std::io::BufRead;
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::normalize::normalize_json_line;
|
||||
|
||||
/// Channel depth for buffered stdin lines. Small: the reader thread blocks on a
|
||||
/// full channel, applying natural backpressure to a flooding peer rather than
|
||||
/// growing memory without bound.
|
||||
const STDIN_LINE_CHANNEL_DEPTH: usize = 64;
|
||||
|
||||
/// Spawn a dedicated OS thread that reads newline-delimited lines from the
|
||||
/// process's standard input with **synchronous, blocking** `std::io` and yields
|
||||
/// each line (its trailing `\n` included, like `read_line`/`read_until`) on the
|
||||
/// returned channel. A final line without a trailing newline is still delivered
|
||||
/// before the channel closes.
|
||||
///
|
||||
/// Yielded lines are **not guaranteed byte-verbatim**: a line the pinned acp
|
||||
/// 0.6 envelope would otherwise drop (a `\/`-escaped `method`, as Foundation
|
||||
/// encoders emit) is re-serialized compactly (key order, whitespace, and
|
||||
/// number formatting normalized) before forwarding — see the crate-private
|
||||
/// `normalize` module. Every line the envelope already accepts, and anything
|
||||
/// that fails to parse, passes through byte-identical (trailing terminator
|
||||
/// always preserved).
|
||||
///
|
||||
/// The channel closes (so [`recv`](mpsc::Receiver::recv) returns `None`) when
|
||||
/// stdin reaches EOF, the read fails, or the [`Receiver`](mpsc::Receiver) is
|
||||
/// dropped. The reader is meant to be the **sole** stdin consumer in the
|
||||
/// agent-stdio / leader-bridge paths; on Windows it enforces that by redirecting
|
||||
/// the process's standard input to `NUL` so stray readers can't deadlock on it
|
||||
/// (see the [module docs](self)).
|
||||
pub fn spawn_stdin_line_reader() -> mpsc::Receiver<Vec<u8>> {
|
||||
let (tx, rx) = mpsc::channel::<Vec<u8>>(STDIN_LINE_CHANNEL_DEPTH);
|
||||
|
||||
// On Windows, synchronously take a private duplicate of the real stdin and
|
||||
// redirect the process's standard input to `NUL` *before* the reader thread
|
||||
// parks in a blocking read holding the global `StdinLock`. After this, any
|
||||
// other `std::io::stdin()` read in the process EOFs immediately instead of
|
||||
// deadlocking. `None` means we couldn't isolate (we fall back to reading
|
||||
// `std::io::stdin()` directly — no worse than before).
|
||||
#[cfg(windows)]
|
||||
let private_stdin: Option<std::fs::File> = isolate_process_stdin();
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("acp-stdin".to_string())
|
||||
.spawn(move || {
|
||||
#[cfg(windows)]
|
||||
if let Some(file) = private_stdin {
|
||||
forward_lines(std::io::BufReader::new(file), &tx);
|
||||
return;
|
||||
}
|
||||
let stdin = std::io::stdin();
|
||||
forward_lines(stdin.lock(), &tx);
|
||||
})
|
||||
.expect("failed to spawn acp-stdin reader thread");
|
||||
rx
|
||||
}
|
||||
|
||||
/// Read `\n`-delimited lines from `reader` and forward each on `tx` — via
|
||||
/// [`normalize_json_line`], so bytes are verbatim except for the lines that
|
||||
/// workaround rewrites (terminator always preserved) — until EOF, a read
|
||||
/// error, or the receiver is dropped.
|
||||
fn forward_lines<R: BufRead>(mut reader: R, tx: &mpsc::Sender<Vec<u8>>) {
|
||||
let mut line = Vec::new();
|
||||
loop {
|
||||
line.clear();
|
||||
match reader.read_until(b'\n', &mut line) {
|
||||
// EOF or a fatal read error: return, dropping `tx` closes the channel.
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
let normalized = normalize_json_line(std::mem::take(&mut line));
|
||||
// `blocking_send` parks this thread (not a runtime worker) when the
|
||||
// channel is full, and errors only once the receiver is dropped — at
|
||||
// which point there is nothing left to feed.
|
||||
if tx.blocking_send(normalized).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Duplicate the real stdin handle for private use and repoint the process's
|
||||
/// `STD_INPUT_HANDLE` at `NUL`, returning the duplicate as an owned [`File`].
|
||||
///
|
||||
/// Returns `None` (caller falls back to `std::io::stdin()`) when there is no
|
||||
/// stdin handle or duplication fails. Win32 declarations are inlined to avoid a
|
||||
/// `windows`/`windows-sys` dependency, matching the pager's console setup.
|
||||
///
|
||||
/// [`File`]: std::fs::File
|
||||
#[cfg(windows)]
|
||||
fn isolate_process_stdin() -> Option<std::fs::File> {
|
||||
use std::os::windows::io::FromRawHandle as _;
|
||||
|
||||
// Win32 constants (inlined to avoid a dependency).
|
||||
const STD_INPUT_HANDLE: u32 = 0xFFFF_FFF6; // (DWORD)-10
|
||||
const DUPLICATE_SAME_ACCESS: u32 = 0x0000_0002;
|
||||
const GENERIC_READ: u32 = 0x8000_0000;
|
||||
const FILE_SHARE_READ: u32 = 0x0000_0001;
|
||||
const FILE_SHARE_WRITE: u32 = 0x0000_0002;
|
||||
const OPEN_EXISTING: u32 = 0x0000_0003;
|
||||
const INVALID_HANDLE: *mut core::ffi::c_void = -1_isize as *mut core::ffi::c_void;
|
||||
|
||||
unsafe extern "system" {
|
||||
fn GetStdHandle(nStdHandle: u32) -> *mut core::ffi::c_void;
|
||||
fn SetStdHandle(nStdHandle: u32, hHandle: *mut core::ffi::c_void) -> i32;
|
||||
fn GetCurrentProcess() -> *mut core::ffi::c_void;
|
||||
fn DuplicateHandle(
|
||||
hSourceProcessHandle: *mut core::ffi::c_void,
|
||||
hSourceHandle: *mut core::ffi::c_void,
|
||||
hTargetProcessHandle: *mut core::ffi::c_void,
|
||||
lpTargetHandle: *mut *mut core::ffi::c_void,
|
||||
dwDesiredAccess: u32,
|
||||
bInheritHandle: i32,
|
||||
dwOptions: u32,
|
||||
) -> i32;
|
||||
fn CreateFileW(
|
||||
lpFileName: *const u16,
|
||||
dwDesiredAccess: u32,
|
||||
dwShareMode: u32,
|
||||
lpSecurityAttributes: *mut core::ffi::c_void,
|
||||
dwCreationDisposition: u32,
|
||||
dwFlagsAndAttributes: u32,
|
||||
hTemplateFile: *mut core::ffi::c_void,
|
||||
) -> *mut core::ffi::c_void;
|
||||
}
|
||||
|
||||
// SAFETY: standard Win32 console/file calls; every return value is checked
|
||||
// before use and the duplicated handle is wrapped in an owning `File`.
|
||||
unsafe {
|
||||
let current = GetStdHandle(STD_INPUT_HANDLE);
|
||||
if current.is_null() || current == INVALID_HANDLE {
|
||||
return None;
|
||||
}
|
||||
|
||||
let process = GetCurrentProcess();
|
||||
let mut duplicate: *mut core::ffi::c_void = std::ptr::null_mut();
|
||||
if DuplicateHandle(
|
||||
process,
|
||||
current,
|
||||
process,
|
||||
&mut duplicate,
|
||||
0,
|
||||
0, // not inheritable
|
||||
DUPLICATE_SAME_ACCESS,
|
||||
) == 0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
// Repoint the process's std input at NUL so stray `std::io::stdin()`
|
||||
// reads observe EOF instead of blocking on the held `StdinLock`. If NUL
|
||||
// can't be opened we still return the duplicate so the reader works;
|
||||
// we just forgo the stray-read isolation.
|
||||
let nul: Vec<u16> = "NUL\0".encode_utf16().collect();
|
||||
let nul_handle = CreateFileW(
|
||||
nul.as_ptr(),
|
||||
GENERIC_READ,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
std::ptr::null_mut(),
|
||||
OPEN_EXISTING,
|
||||
0,
|
||||
std::ptr::null_mut(),
|
||||
);
|
||||
if nul_handle != INVALID_HANDLE && !nul_handle.is_null() {
|
||||
SetStdHandle(STD_INPUT_HANDLE, nul_handle);
|
||||
}
|
||||
|
||||
Some(std::fs::File::from_raw_handle(duplicate as _))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue