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
5 changes: 5 additions & 0 deletions Cargo.lock

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

5 changes: 5 additions & 0 deletions crates/zeph-acp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,17 @@ readme = "README.md"
[dependencies]
agent-client-protocol.workspace = true
async-trait.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["io-std", "sync", "macros"] }
tokio-util = { workspace = true, features = ["compat"] }
tracing.workspace = true
uuid = { workspace = true, features = ["v4"] }
zeph-core.workspace = true
zeph-mcp.workspace = true
zeph-tools.workspace = true

[lints]
workspace = true
116 changes: 112 additions & 4 deletions crates/zeph-acp/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,29 @@ use tokio::sync::{mpsc, oneshot};
use zeph_core::LoopbackEvent;
use zeph_core::channel::{ChannelMessage, LoopbackChannel};

use crate::fs::AcpFileExecutor;
use crate::permission::AcpPermissionGate;
use crate::terminal::AcpShellExecutor;

const MAX_PROMPT_BYTES: usize = 1_048_576; // 1 MiB
const MAX_SESSIONS: usize = 1;

/// Factory: receives a [`LoopbackChannel`] and runs the agent loop on it.
/// IDE-proxied capabilities passed to the agent loop per session.
///
/// Each field is `None` when the IDE did not advertise the corresponding capability.
pub struct AcpContext {
pub file_executor: Option<AcpFileExecutor>,
pub shell_executor: Option<AcpShellExecutor>,
pub permission_gate: Option<AcpPermissionGate>,
}

/// Factory: receives a [`LoopbackChannel`] and optional [`AcpContext`], runs the agent loop.
pub type AgentSpawner = Arc<
dyn Fn(LoopbackChannel) -> Pin<Box<dyn std::future::Future<Output = ()> + 'static>> + 'static,
dyn Fn(
LoopbackChannel,
Option<AcpContext>,
) -> Pin<Box<dyn std::future::Future<Output = ()> + 'static>>
+ 'static,
>;

/// Sender half for delivering session notifications to the background writer.
Expand Down Expand Up @@ -91,7 +108,9 @@ impl acp::Agent for ZephAcpAgent {

let spawner = Arc::clone(&self.spawner);
tokio::task::spawn_local(async move {
(spawner)(channel).await;
// AcpContext is wired in transport.rs when IDE capabilities are known.
// Here we spawn without context (no-IDE or capability-unaware path).
(spawner)(channel, None).await;
});

Ok(acp::NewSessionResponse::new(session_id))
Expand Down Expand Up @@ -197,7 +216,7 @@ mod tests {
use super::*;

fn make_spawner() -> AgentSpawner {
Arc::new(|_channel| Box::pin(async {}))
Arc::new(|_channel, _ctx| Box::pin(async {}))
}

fn make_agent() -> (
Expand Down Expand Up @@ -308,4 +327,93 @@ mod tests {
assert!(loopback_event_to_update(LoopbackEvent::FullMessage(String::new())).is_none());
assert!(loopback_event_to_update(LoopbackEvent::Status(String::new())).is_none());
}

#[test]
fn loopback_tool_output_maps_to_agent_message() {
let event = LoopbackEvent::ToolOutput {
tool_name: "bash".to_owned(),
display: "done".to_owned(),
diff: None,
filter_stats: None,
kept_lines: None,
};
assert!(matches!(
loopback_event_to_update(event),
Some(acp::SessionUpdate::AgentMessageChunk(_))
));
}

#[tokio::test]
async fn new_session_rejects_over_limit() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (agent, _rx) = make_agent();
use acp::Agent as _;
// fill the limit
agent
.new_session(acp::NewSessionRequest::new(std::path::PathBuf::from(".")))
.await
.unwrap();
// second session should fail
let res = agent
.new_session(acp::NewSessionRequest::new(std::path::PathBuf::from(".")))
.await;
assert!(res.is_err());
})
.await;
}

#[tokio::test]
async fn load_session_returns_ok_for_existing() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (agent, _rx) = make_agent();
use acp::Agent as _;
let resp = agent
.new_session(acp::NewSessionRequest::new(std::path::PathBuf::from(".")))
.await
.unwrap();
let res = agent
.load_session(acp::LoadSessionRequest::new(
resp.session_id,
std::path::PathBuf::from("."),
))
.await;
assert!(res.is_ok());
})
.await;
}

#[tokio::test]
async fn load_session_errors_for_unknown() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (agent, _rx) = make_agent();
use acp::Agent as _;
let res = agent
.load_session(acp::LoadSessionRequest::new(
acp::SessionId::new("no-such"),
std::path::PathBuf::from("."),
))
.await;
assert!(res.is_err());
})
.await;
}

#[tokio::test]
async fn prompt_errors_for_unknown_session() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (agent, _rx) = make_agent();
use acp::Agent as _;
let req = acp::PromptRequest::new("no-such", vec![]);
assert!(agent.prompt(req).await.is_err());
})
.await;
}
}
9 changes: 9 additions & 0 deletions crates/zeph-acp/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,13 @@
pub enum AcpError {
#[error("transport error: {0}")]
Transport(String),

#[error("IDE returned error: {0}")]
ClientError(String),

#[error("capability not available: {0}")]
CapabilityUnavailable(String),

#[error("channel closed")]
ChannelClosed,
}
Loading
Loading