#[cfg(feature = "unstable-llm-providers")]
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use parking_lot::{Mutex, RwLock};
use agent_client_protocol as acp;
use tokio::sync::{mpsc, oneshot};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use zeph_common::task_supervisor::TaskSupervisor;
use zeph_core::channel::{ChannelMessage, LoopbackChannel};
use zeph_core::{ContentSanitizer, LoopbackEvent, StopHint};
use zeph_llm::any::AnyProvider;
use zeph_mcp::McpManager;
use zeph_memory::ConversationId;
use zeph_memory::store::SqliteStore;
use crate::fs::AcpFileExecutor;
use crate::lsp::DiagnosticsCache;
use crate::permission::AcpPermissionGate;
use crate::terminal::AcpShellExecutor;
use crate::transport::SharedAvailableModels;
pub type ProviderFactory = Arc<dyn Fn(&str) -> Option<AnyProvider> + Send + Sync>;
pub struct SessionContext {
pub session_id: acp::schema::v1::SessionId,
pub conversation_id: Option<ConversationId>,
pub working_dir: PathBuf,
}
pub struct AcpContext {
pub file_executor: Option<AcpFileExecutor>,
pub shell_executor: Option<AcpShellExecutor>,
pub permission_gate: Option<AcpPermissionGate>,
pub cancel_signal: std::sync::Arc<tokio::sync::Notify>,
pub provider_override: Arc<RwLock<Option<AnyProvider>>>,
pub parent_tool_use_id: Option<String>,
pub lsp_provider: Option<crate::lsp::AcpLspProvider>,
pub diagnostics_cache: Arc<RwLock<DiagnosticsCache>>,
pub status_notifier: SessionStatusNotifier,
#[cfg(feature = "unstable-elicitation")]
#[allow(dead_code)]
pub(crate) elicitation_bridge: Option<elicitation::ElicitationBridge>,
}
pub type AgentSpawner = Arc<
dyn Fn(
LoopbackChannel,
Option<AcpContext>,
SessionContext,
) -> Pin<Box<dyn std::future::Future<Output = ()> + 'static>>
+ Send
+ Sync
+ 'static,
>;
#[cfg(feature = "acp-http")]
#[cfg_attr(docsrs, doc(cfg(feature = "acp-http")))]
pub type SendAgentSpawner = AgentSpawner;
pub type NotifySender = mpsc::Sender<(acp::schema::v1::SessionNotification, oneshot::Sender<()>)>;
pub(crate) type NotifyReceiver =
mpsc::Receiver<(acp::schema::v1::SessionNotification, oneshot::Sender<()>)>;
#[derive(Clone)]
pub struct SessionStatusNotifier {
notify_tx: NotifySender,
session_id: acp::schema::v1::SessionId,
}
impl SessionStatusNotifier {
#[must_use]
pub fn new(notify_tx: NotifySender, session_id: acp::schema::v1::SessionId) -> Self {
Self {
notify_tx,
session_id,
}
}
pub fn notify_status_nowait(&self, text: impl Into<String>) {
let text = text.into();
if text.is_empty() {
return;
}
let update = acp::schema::v1::SessionUpdate::AgentThoughtChunk(
acp::schema::v1::ContentChunk::new(text.into()),
);
let notification =
acp::schema::v1::SessionNotification::new(self.session_id.clone(), update);
let (ack_tx, _) = oneshot::channel();
if let Err(e) = self.notify_tx.try_send((notification, ack_tx)) {
tracing::warn!(
error = %e,
"proactive session status notification dropped: channel full or closed"
);
}
}
}
pub(crate) struct SessionConfigSeed {
thinking_enabled: bool,
auto_approve_level: String,
temperature_preset: zeph_config::AcpTemperaturePreset,
}
static SESSION_ENTRY_GENERATION: AtomicU64 = AtomicU64::new(0);
pub(crate) struct SessionEntry {
pub(crate) input_tx: mpsc::Sender<ChannelMessage>,
pub(crate) output_rx: Mutex<Option<mpsc::Receiver<LoopbackEvent>>>,
pub(crate) generation: u64,
pub(crate) cancel_signal: Arc<tokio::sync::Notify>,
pub(crate) last_active_ms: AtomicU64,
pub(crate) created_at: chrono::DateTime<chrono::Utc>,
pub(crate) working_dir: Mutex<Option<std::path::PathBuf>>,
pub(crate) notify_tx: NotifySender,
pub(crate) notify_rx: Mutex<Option<NotifyReceiver>>,
provider_override: Arc<RwLock<Option<AnyProvider>>>,
current_model: Mutex<String>,
current_mode: Mutex<acp::schema::v1::SessionModeId>,
first_prompt_done: AtomicBool,
title: Mutex<Option<String>>,
thinking_enabled: AtomicBool,
auto_approve_level: Mutex<String>,
temperature_preset: Mutex<zeph_config::AcpTemperaturePreset>,
pub(crate) shell_executor: Option<AcpShellExecutor>,
pub(crate) agent_loop_handle: Mutex<Option<JoinHandle<()>>>,
#[cfg(feature = "unstable-elicitation")]
pub(crate) elicitation_bridge_handle: Option<JoinHandle<()>>,
#[cfg(feature = "unstable-session-usage")]
pub(crate) usage_accumulator: Mutex<SessionUsageAccumulator>,
}
impl Drop for SessionEntry {
fn drop(&mut self) {
if let Some(handle) = self.agent_loop_handle.lock().take() {
handle.abort();
}
#[cfg(feature = "unstable-elicitation")]
if let Some(handle) = self.elicitation_bridge_handle.take() {
handle.abort();
}
}
}
impl SessionEntry {
#[allow(dead_code)]
fn last_active(&self) -> std::time::Instant {
let ms = self.last_active_ms.load(Ordering::Relaxed);
let now_ms = u64::try_from(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
)
.unwrap_or(u64::MAX);
let elapsed_ms = now_ms.saturating_sub(ms);
std::time::Instant::now()
.checked_sub(std::time::Duration::from_millis(elapsed_ms))
.unwrap_or_else(std::time::Instant::now)
}
fn touch(&self) {
let ms = u64::try_from(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
)
.unwrap_or(u64::MAX);
self.last_active_ms.store(ms, Ordering::Relaxed);
}
}
type SessionMap = Arc<Mutex<std::collections::HashMap<acp::schema::v1::SessionId, SessionEntry>>>;
pub struct ZephAcpAgentState {
pub(crate) spawner: AgentSpawner,
pub(crate) sessions: SessionMap,
pub(crate) agent_name: String,
agent_version: String,
max_sessions: usize,
idle_timeout: std::time::Duration,
pub(crate) store: Option<SqliteStore>,
pub(crate) session_data_dir: Option<std::path::PathBuf>,
permission_file: Option<std::path::PathBuf>,
pub(crate) client_caps: RwLock<acp::schema::v1::ClientCapabilities>,
pub(crate) provider_factory: Option<ProviderFactory>,
available_models: SharedAvailableModels,
pub(crate) mcp_manager: Option<Arc<McpManager>>,
project_rules: Vec<std::path::PathBuf>,
title_max_chars: usize,
max_history: usize,
pub(crate) lsp_config: zeph_core::config::AcpLspConfig,
pub(crate) diagnostics_cache: Arc<RwLock<DiagnosticsCache>>,
reaper_cancel: CancellationToken,
task_supervisor: TaskSupervisor,
additional_directories_allow: Vec<std::path::PathBuf>,
auth_methods_config: Vec<zeph_core::config::AcpAuthMethod>,
pub(crate) timeouts: zeph_config::AcpTimeoutsConfig,
pub(crate) model_config: zeph_config::AcpModelConfigConfig,
prompt_injection_detector: ContentSanitizer,
#[cfg(feature = "unstable-elicitation")]
pub(crate) elicitation_supported: std::sync::atomic::AtomicBool,
#[cfg(feature = "unstable-llm-providers")]
pub(crate) provider_names: Vec<(String, agent_client_protocol_schema::v1::LlmProtocol)>,
#[cfg(feature = "unstable-llm-providers")]
pub(crate) global_disabled_providers: Mutex<HashSet<String>>,
#[cfg(feature = "unstable-llm-providers")]
pub(crate) global_provider_overrides: Mutex<HashMap<String, ProviderSetOverride>>,
pub(crate) owner_key: String,
}
pub type ZephAcpAgent = ZephAcpAgentState;
impl ZephAcpAgentState {
#[cfg(feature = "unstable-cancel-request")]
pub(crate) fn session_cancel_signal(
&self,
session_id: &acp::schema::v1::SessionId,
) -> Option<Arc<tokio::sync::Notify>> {
self.sessions
.lock()
.get(session_id)
.map(|entry| Arc::clone(&entry.cancel_signal))
}
#[allow(clippy::too_many_arguments)] pub(crate) async fn build_acp_context(
&self,
session_id: &acp::schema::v1::SessionId,
cx: &acp::ConnectionTo<acp::Client>,
cancel_signal: Arc<tokio::sync::Notify>,
provider_override: Arc<RwLock<Option<AnyProvider>>>,
cwd: PathBuf,
notify_tx: NotifySender,
#[cfg(feature = "unstable-elicitation")] elicitation_tx: Option<
elicitation::ElicitationSender,
>,
) -> AcpContext {
let (can_read, can_write, ide_supports_lsp) = {
let caps = self.client_caps.read();
let r = caps.fs.read_text_file;
let w = caps.fs.write_text_file;
let lsp = self.lsp_config.enabled
&& caps.meta.as_ref().is_some_and(|m| m.contains_key("lsp"));
(r, w, lsp)
};
let conn = Arc::new(cx.clone());
let (perm_gate, perm_handler) =
AcpPermissionGate::new(Arc::clone(&conn), self.permission_file.clone());
tokio::spawn(perm_handler);
let (fs_exec, fs_handler) = AcpFileExecutor::new(
Arc::clone(&conn),
session_id.clone(),
can_read,
can_write,
cwd,
Some(perm_gate.clone()),
)
.await;
tokio::spawn(fs_handler);
let (shell_exec, shell_handler) = AcpShellExecutor::new(
Arc::clone(&conn),
session_id.clone(),
Some(perm_gate.clone()),
self.timeouts.terminal_secs,
);
tokio::spawn(shell_handler);
let lsp_provider = if ide_supports_lsp {
let (provider, lsp_handler) = crate::lsp::AcpLspProvider::new(
Arc::clone(&conn),
true,
self.lsp_config.request_timeout_secs,
self.lsp_config.max_references,
self.lsp_config.max_workspace_symbols,
);
tokio::spawn(lsp_handler);
Some(provider)
} else {
None
};
AcpContext {
file_executor: Some(fs_exec),
shell_executor: Some(shell_exec),
permission_gate: Some(perm_gate),
cancel_signal,
provider_override,
parent_tool_use_id: None,
lsp_provider,
diagnostics_cache: Arc::clone(&self.diagnostics_cache),
status_notifier: SessionStatusNotifier::new(notify_tx, session_id.clone()),
#[cfg(feature = "unstable-elicitation")]
elicitation_bridge: elicitation_tx.map(|tx| elicitation::ElicitationBridge {
tx,
timeout_secs: self.timeouts.elicitation_secs,
}),
}
}
pub(crate) async fn send_notification(
&self,
session_id: &acp::schema::v1::SessionId,
notification: acp::schema::v1::SessionNotification,
) -> acp::Result<()> {
let tx = self
.sessions
.lock()
.get(session_id)
.map(|e| e.notify_tx.clone());
let Some(tx) = tx else {
return Err(acp::Error::internal_error().data("session not found"));
};
let (ack_tx, ack_rx) = oneshot::channel();
tx.send((notification, ack_tx))
.await
.map_err(|_| acp::Error::internal_error().data("notification channel closed"))?;
let timeout = std::time::Duration::from_millis(self.timeouts.notify_ack_timeout_ms);
tokio::time::timeout(timeout, ack_rx)
.await
.map_err(|_| {
tracing::warn!(
timeout_ms = self.timeouts.notify_ack_timeout_ms,
"notification ack timed out — IDE client may be hung"
);
acp::Error::internal_error().data("notification ack timed out")
})?
.map_err(|_| acp::Error::internal_error().data("notification ack lost"))
}
pub(crate) fn send_notification_nowait(
&self,
session_id: &acp::schema::v1::SessionId,
notification: acp::schema::v1::SessionNotification,
) {
let tx = self
.sessions
.lock()
.get(session_id)
.map(|e| e.notify_tx.clone());
if let Some(tx) = tx {
let (ack_tx, _) = oneshot::channel();
if let Err(e) = tx.try_send((notification, ack_tx)) {
tracing::warn!(error = %e, "session notification dropped: channel full or closed");
}
}
}
}
impl ZephAcpAgentState {
#[allow(clippy::unused_async)]
#[tracing::instrument(skip_all, name = "acp.handler.initialize")]
pub(crate) async fn do_initialize(
&self,
args: acp::schema::v1::InitializeRequest,
) -> acp::Result<acp::schema::v1::InitializeResponse> {
tracing::debug!("ACP initialize");
#[cfg(feature = "unstable-elicitation")]
{
let supports = args.client_capabilities.elicitation.is_some();
self.elicitation_supported
.store(supports, std::sync::atomic::Ordering::Relaxed);
tracing::debug!(
elicitation_supported = supports,
"ACP initialize: elicitation capability"
);
}
*self.client_caps.write() = args.client_capabilities;
let title = format!("{} AI Agent", self.agent_name);
let mut meta = serde_json::Map::new();
meta.insert(
"auth_hint".to_owned(),
serde_json::json!("authentication required"),
);
let mut caps = acp::schema::v1::AgentCapabilities::new()
.load_session(true)
.prompt_capabilities(
acp::schema::v1::PromptCapabilities::new()
.image(true)
.embedded_context(true),
)
.meta({
let mut cap_meta = serde_json::Map::new();
cap_meta.insert("config_options".to_owned(), serde_json::json!(true));
cap_meta.insert("ext_methods".to_owned(), serde_json::json!(true));
if self.lsp_config.enabled {
cap_meta.insert(
"lsp".to_owned(),
serde_json::json!({
"methods": crate::lsp::LSP_METHODS,
"notifications": crate::lsp::LSP_NOTIFICATIONS,
}),
);
}
cap_meta
});
if self.mcp_manager.is_some() {
caps = caps.mcp_capabilities(
acp::schema::v1::McpCapabilities::new()
.http(true)
.sse(false),
);
}
#[cfg(any(
feature = "unstable-session-delete",
feature = "unstable-session-fork",
feature = "unstable-session-resume",
))]
let caps = {
let mut session_caps = acp::schema::v1::SessionCapabilities::new();
session_caps = session_caps.list(acp::schema::v1::SessionListCapabilities::default());
{
session_caps =
session_caps.close(acp::schema::v1::SessionCloseCapabilities::default());
}
#[cfg(feature = "unstable-session-fork")]
{
session_caps =
session_caps.fork(acp::schema::v1::SessionForkCapabilities::default());
}
{
session_caps =
session_caps.resume(acp::schema::v1::SessionResumeCapabilities::default());
}
caps.session_capabilities(session_caps)
};
let caps = caps.auth(
acp::schema::v1::AgentAuthCapabilities::default()
.logout(acp::schema::v1::LogoutCapabilities::default()),
);
let auth_methods: Vec<acp::schema::v1::AuthMethod> = self
.auth_methods_config
.iter()
.map(|_m| {
acp::schema::v1::AuthMethod::Agent(acp::schema::v1::AuthMethodAgent::new(
"zeph", "Zeph",
))
})
.collect();
Ok(
acp::schema::v1::InitializeResponse::new(acp::schema::ProtocolVersion::LATEST)
.auth_methods(auth_methods)
.agent_info(
acp::schema::v1::Implementation::new(&self.agent_name, &self.agent_version)
.title(title),
)
.agent_capabilities(caps)
.meta(meta),
)
}
#[tracing::instrument(skip_all, name = "acp.handler.dispatch")]
pub(crate) async fn do_ext_method(
&self,
args: acp::schema::v1::ExtRequest,
) -> acp::Result<acp::schema::v1::ExtResponse> {
if let Some(fut) = crate::custom::dispatch(self, &args) {
return fut.await;
}
#[cfg(feature = "unstable-llm-providers")]
{
if let Some(resp) = self.ext_method_providers(&args)? {
return Ok(resp);
}
}
self.ext_method_mcp(&args).await
}
pub(crate) async fn do_ext_notification(
&self,
args: acp::schema::v1::ExtNotification,
cx: &acp::ConnectionTo<acp::Client>,
) -> acp::Result<()> {
tracing::debug!(method = %args.method, "received ext_notification");
match args.method.as_ref() {
"lsp/publishDiagnostics" => {
self.handle_lsp_publish_diagnostics(args.params.get());
}
"lsp/didSave" => {
self.handle_lsp_did_save(args.params.get(), cx).await;
}
_ => {}
}
Ok(())
}
#[allow(clippy::unused_async)]
#[tracing::instrument(skip_all, name = "acp.handler.authenticate")]
pub(crate) async fn do_authenticate(
&self,
_args: acp::schema::v1::AuthenticateRequest,
) -> acp::Result<acp::schema::v1::AuthenticateResponse> {
Ok(acp::schema::v1::AuthenticateResponse::default())
}
#[allow(clippy::unused_async)]
#[tracing::instrument(skip_all, name = "acp.handler.logout")]
pub(crate) async fn do_logout(
&self,
_args: acp::schema::v1::LogoutRequest,
) -> acp::Result<acp::schema::v1::LogoutResponse> {
tracing::debug!("ACP logout (no-op: vault-based auth)");
Ok(acp::schema::v1::LogoutResponse::default())
}
#[allow(clippy::unused_async)]
#[tracing::instrument(skip_all, name = "acp.handler.cancel", fields(session_id = %args.session_id))]
pub(crate) async fn do_cancel(
&self,
args: acp::schema::v1::CancelNotification,
) -> acp::Result<()> {
tracing::debug!(session_id = %args.session_id, "ACP cancel");
if let Some(entry) = self.sessions.lock().get(&args.session_id) {
entry.cancel_signal.notify_one();
}
Ok(())
}
}
fn session_event_to_updates(
event: zeph_session::SessionEvent,
) -> Vec<acp::schema::v1::SessionUpdate> {
match event {
zeph_session::SessionEvent::UserMessage { text, .. } => {
vec![acp::schema::v1::SessionUpdate::UserMessageChunk(
acp::schema::v1::ContentChunk::new(text.into()),
)]
}
zeph_session::SessionEvent::AssistantMessage { parts } => parts
.into_iter()
.filter_map(|part| match part {
zeph_llm::provider::MessagePart::ToolUse { id, name, input } => {
Some(acp::schema::v1::SessionUpdate::ToolCall(
acp::schema::v1::ToolCall::new(id, name).raw_input(input),
))
}
other => other.as_plain_text().map(|text| {
acp::schema::v1::SessionUpdate::AgentMessageChunk(
acp::schema::v1::ContentChunk::new(text.to_owned().into()),
)
}),
})
.collect(),
zeph_session::SessionEvent::ToolCall { id, name, input } => {
vec![acp::schema::v1::SessionUpdate::ToolCall(
acp::schema::v1::ToolCall::new(id, name).raw_input(input),
)]
}
zeph_session::SessionEvent::ToolResult {
id,
output,
is_error,
..
} => {
let status = if is_error {
acp::schema::v1::ToolCallStatus::Failed
} else {
acp::schema::v1::ToolCallStatus::Completed
};
vec![acp::schema::v1::SessionUpdate::ToolCallUpdate(
acp::schema::v1::ToolCallUpdate::new(
id,
acp::schema::v1::ToolCallUpdateFields::new()
.status(status)
.content(vec![output.into()]),
),
)]
}
zeph_session::SessionEvent::SessionStarted { .. }
| zeph_session::SessionEvent::ForkPoint { .. }
| zeph_session::SessionEvent::Condensation { .. }
| zeph_session::SessionEvent::Compaction { .. }
| zeph_session::SessionEvent::ModelChanged { .. }
| zeph_session::SessionEvent::SessionEnded { .. } => Vec::new(),
}
}
fn is_acp_native_slash_command(trimmed_text: &str) -> bool {
trimmed_text == "/help"
|| trimmed_text.starts_with("/help ")
|| trimmed_text == "/mode"
|| trimmed_text.starts_with("/mode ")
|| trimmed_text == "/clear"
|| trimmed_text == "/model"
|| trimmed_text.starts_with("/model ")
}
pub async fn warm_model_caches(
provider: zeph_llm::any::AnyProvider,
available_models: SharedAvailableModels,
) -> usize {
use zeph_llm::model_cache::ModelCache;
let provider_count = {
let models = available_models.read();
models
.iter()
.filter_map(|k| k.split_once(':').map(|(slug, _)| slug))
.collect::<std::collections::HashSet<_>>()
.len()
};
tracing::info!(
providers = provider_count,
"warming model caches in background"
);
let fetch = async move {
match provider.list_models_remote().await {
Ok(models) => {
let count = models.len();
tracing::info!(models = count, "model cache fetch completed");
count
}
Err(e) => {
tracing::info!(error = %e, "model cache warm-up failed; keeping fallback list");
0
}
}
};
let Ok(fetched) = tokio::time::timeout(std::time::Duration::from_secs(5), fetch).await else {
tracing::info!("model cache warm-up timed out; keeping fallback list");
return 0;
};
let slugs: Vec<String> = {
let models = available_models.read();
models
.iter()
.filter_map(|k| k.split_once(':').map(|(s, _)| s.to_owned()))
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect()
};
for slug in slugs {
let cache = ModelCache::for_slug(&slug);
if cache.is_stale_async().await {
tracing::info!(provider = %slug, "model cache still stale after warm-up");
continue;
}
if let Ok(Some(entries)) = cache.load_async().await
&& !entries.is_empty()
{
let new_keys: Vec<String> = entries
.into_iter()
.map(|m| format!("{slug}:{}", m.id))
.collect();
let count = new_keys.len();
let mut models = available_models.write();
models.retain(|k| !k.starts_with(&format!("{slug}:")));
models.extend(new_keys);
models.dedup();
tracing::info!(provider = %slug, models = count, "model cache ready");
}
}
let total_models = available_models.read().len();
tracing::info!(models = total_models, "model cache warming finished");
fetched
}
fn compute_stop_reason(
cancelled: bool,
stop_hint: Option<StopHint>,
) -> acp::schema::v1::StopReason {
if cancelled {
acp::schema::v1::StopReason::Cancelled
} else {
match stop_hint {
Some(StopHint::MaxTokens) => acp::schema::v1::StopReason::MaxTokens,
Some(StopHint::MaxTurnRequests) => acp::schema::v1::StopReason::MaxTurnRequests,
None | Some(_) => acp::schema::v1::StopReason::EndTurn,
}
}
}
fn build_prompt_response(
stop_reason: acp::schema::v1::StopReason,
#[cfg(feature = "unstable-session-usage")] turn_usage: TurnUsage,
) -> acp::schema::v1::PromptResponse {
let r = acp::schema::v1::PromptResponse::new(stop_reason);
#[cfg(feature = "unstable-session-usage")]
let r = {
let total = turn_usage
.input_tokens
.saturating_add(turn_usage.output_tokens);
let usage =
acp::schema::v1::Usage::new(total, turn_usage.input_tokens, turn_usage.output_tokens)
.cached_read_tokens(
(turn_usage.cache_read_tokens > 0).then_some(turn_usage.cache_read_tokens),
)
.cached_write_tokens(
(turn_usage.cache_write_tokens > 0).then_some(turn_usage.cache_write_tokens),
);
r.usage(usage)
};
r
}
#[cfg(feature = "unstable-elicitation")]
pub(crate) mod elicitation;
#[cfg(feature = "unstable-llm-providers")]
mod providers;
#[cfg(feature = "unstable-llm-providers")]
pub(crate) use providers::ProviderSetOverride;
#[cfg(feature = "unstable-session-usage")]
mod usage;
#[cfg(feature = "unstable-session-usage")]
pub(crate) use usage::{SessionUsageAccumulator, TurnUsage};
pub(super) mod helpers;
use helpers::{
DEFAULT_MODE_ID, DIAGNOSTICS_MIME_TYPE, build_available_commands, build_config_options,
build_mode_state, format_diagnostics_block, loopback_event_to_updates, mime_to_ext, model_meta,
};
use zeph_common::text::xml_escape;
pub(crate) mod handlers;
mod builder;
mod lsp_events;
mod mcp_ext;
mod model;
mod reaper;
mod session;
mod slash;
mod turn;
macro_rules! req_handler {
($state:expr, $handler:path) => {{
let s = Arc::clone(&$state);
move |req, responder, cx| {
let s = Arc::clone(&s);
async move { $handler(req, responder, cx, s).await }
}
}};
}
macro_rules! notif_handler {
($state:expr, $handler:path) => {{
let s = Arc::clone(&$state);
move |notif, cx| {
let s = Arc::clone(&s);
async move { $handler(notif, cx, s).await }
}
}};
}
#[allow(clippy::too_many_lines)]
pub async fn run_agent(
state: Arc<ZephAcpAgentState>,
transport: impl acp::ConnectTo<acp::Agent>,
) -> acp::Result<()> {
#[cfg(feature = "unstable-session-fork")]
use handlers::fork_session;
use handlers::{
authenticate, cancel, close_session, delete_session, dispatch, initialize, list_sessions,
load_session, logout, new_session, prompt, resume_session, set_session_config_option,
set_session_mode,
};
let builder = acp::Agent
.builder()
.on_receive_request(
req_handler!(state, initialize::handle_initialize),
acp::on_receive_request!(),
)
.on_receive_request(
req_handler!(state, authenticate::handle_authenticate),
acp::on_receive_request!(),
)
.on_receive_request(
req_handler!(state, new_session::handle_new_session),
acp::on_receive_request!(),
)
.on_receive_request(
req_handler!(state, prompt::handle_prompt),
acp::on_receive_request!(),
)
.on_receive_request(
req_handler!(state, list_sessions::handle_list_sessions),
acp::on_receive_request!(),
)
.on_receive_request(
req_handler!(state, load_session::handle_load_session),
acp::on_receive_request!(),
)
.on_receive_request(
req_handler!(
state,
set_session_config_option::handle_set_session_config_option
),
acp::on_receive_request!(),
)
.on_receive_request(
req_handler!(state, set_session_mode::handle_set_session_mode),
acp::on_receive_request!(),
)
.on_receive_notification(
notif_handler!(state, cancel::handle_cancel),
acp::on_receive_notification!(),
);
let builder = builder.on_receive_request(
req_handler!(state, close_session::handle_close_session),
acp::on_receive_request!(),
);
let builder = builder.on_receive_request(
req_handler!(state, delete_session::handle_delete_session),
acp::on_receive_request!(),
);
#[cfg(feature = "unstable-session-fork")]
let builder = builder.on_receive_request(
req_handler!(state, fork_session::handle_fork_session),
acp::on_receive_request!(),
);
let builder = builder.on_receive_request(
req_handler!(state, resume_session::handle_resume_session),
acp::on_receive_request!(),
);
let builder = builder.on_receive_request(
req_handler!(state, logout::handle_logout),
acp::on_receive_request!(),
);
#[cfg(feature = "unstable-cancel-request")]
let builder = builder.on_receive_notification(
notif_handler!(state, handlers::cancel_request::handle_cancel_request),
acp::on_receive_notification!(),
);
builder
.on_receive_dispatch(
{
let s = Arc::clone(&state);
move |msg, cx| {
let s = Arc::clone(&s);
async move { dispatch::handle_dispatch(msg, cx, s).await }
}
},
acp::on_receive_dispatch!(),
)
.connect_to(transport)
.await
}
const _: () = {
#[allow(clippy::used_underscore_items)]
fn assert_send_sync<T: Send + Sync>() {}
fn check_send_sync() {
assert_send_sync::<ZephAcpAgentState>();
assert_send_sync::<crate::fs::AcpFileExecutor>();
assert_send_sync::<crate::terminal::AcpShellExecutor>();
assert_send_sync::<crate::permission::AcpPermissionGate>();
}
let _ = check_send_sync;
};
#[cfg(test)]
mod notify_timeout_tests {
use std::sync::Arc;
use parking_lot::RwLock;
use zeph_core::channel::LoopbackChannel;
use zeph_llm::any::AnyProvider;
use super::*;
fn make_agent_for_timeout() -> ZephAcpAgent {
let spawner: AgentSpawner = Arc::new(|_ch, _ctx, _sc| Box::pin(async {}));
let mut agent = ZephAcpAgent::new(spawner, 4, 1800, None);
agent.timeouts.notify_ack_timeout_ms = 50;
agent
}
#[tokio::test]
async fn send_notification_returns_error_when_ack_times_out() {
let agent = make_agent_for_timeout();
let session_id = acp::schema::v1::SessionId::new("timeout-test".to_owned());
let (_, handle) = LoopbackChannel::pair(4);
let provider_override = Arc::new(RwLock::new(None::<AnyProvider>));
let (notify_tx, notify_rx) = mpsc::channel(256);
let entry = ZephAcpAgent::make_session_entry(
handle,
"test-model".to_owned(),
std::path::PathBuf::from("."),
None,
provider_override,
SessionConfigSeed {
thinking_enabled: false,
auto_approve_level: "suggest".to_owned(),
temperature_preset: zeph_config::AcpTemperaturePreset::default(),
},
notify_tx,
notify_rx,
);
agent.sessions.lock().insert(session_id.clone(), entry);
let update = acp::schema::v1::SessionUpdate::AgentMessageChunk(
acp::schema::v1::ContentChunk::new("hello".into()),
);
let notif = acp::schema::v1::SessionNotification::new(session_id.clone(), update);
let result = agent.send_notification(&session_id, notif).await;
assert!(
result.is_err(),
"send_notification must fail when ack does not arrive within the timeout"
);
}
}
#[cfg(test)]
mod session_status_notifier_tests {
use super::*;
#[tokio::test]
async fn notify_status_nowait_delivers_agent_thought_chunk_immediately() {
let (notify_tx, mut notify_rx) = mpsc::channel(4);
let session_id = acp::schema::v1::SessionId::new("notifier-test".to_owned());
let notifier = SessionStatusNotifier::new(notify_tx, session_id.clone());
notifier.notify_status_nowait("degraded");
let (notification, _ack) = notify_rx.try_recv().expect(
"notify_status_nowait must push onto the channel synchronously, without a drainer",
);
assert_eq!(notification.session_id, session_id);
match notification.update {
acp::schema::v1::SessionUpdate::AgentThoughtChunk(chunk) => match chunk.content {
acp::schema::v1::ContentBlock::Text(t) => assert_eq!(t.text, "degraded"),
other => panic!("expected ContentBlock::Text, got {other:?}"),
},
other => panic!("expected AgentThoughtChunk, got {other:?}"),
}
}
#[tokio::test]
async fn notify_status_nowait_skips_empty_text() {
let (notify_tx, mut notify_rx) = mpsc::channel(4);
let session_id = acp::schema::v1::SessionId::new("notifier-empty-test".to_owned());
let notifier = SessionStatusNotifier::new(notify_tx, session_id);
notifier.notify_status_nowait("");
assert!(notify_rx.try_recv().is_err(), "empty text must not be sent");
}
}
#[cfg(test)]
mod session_event_replay_tests {
use super::*;
#[test]
fn user_message_becomes_user_message_chunk() {
let updates = session_event_to_updates(zeph_session::SessionEvent::UserMessage {
text: "hello".to_owned(),
image_refs: Vec::new(),
});
assert_eq!(updates.len(), 1);
assert!(matches!(
updates[0],
acp::schema::v1::SessionUpdate::UserMessageChunk(_)
));
}
#[test]
fn assistant_text_part_becomes_agent_message_chunk() {
let updates = session_event_to_updates(zeph_session::SessionEvent::AssistantMessage {
parts: vec![zeph_llm::provider::MessagePart::Text {
text: "hi there".to_owned(),
}],
});
assert_eq!(updates.len(), 1);
assert!(matches!(
updates[0],
acp::schema::v1::SessionUpdate::AgentMessageChunk(_)
));
}
#[test]
fn assistant_tool_use_part_becomes_tool_call() {
let updates = session_event_to_updates(zeph_session::SessionEvent::AssistantMessage {
parts: vec![zeph_llm::provider::MessagePart::ToolUse {
id: "call_0".to_owned(),
name: "shell".to_owned(),
input: serde_json::json!({"cmd": "ls"}),
}],
});
assert_eq!(updates.len(), 1);
assert!(matches!(
updates[0],
acp::schema::v1::SessionUpdate::ToolCall(_)
));
}
#[test]
fn assistant_message_maps_each_part_independently() {
let updates = session_event_to_updates(zeph_session::SessionEvent::AssistantMessage {
parts: vec![
zeph_llm::provider::MessagePart::ToolUse {
id: "call_0".to_owned(),
name: "shell".to_owned(),
input: serde_json::json!({}),
},
zeph_llm::provider::MessagePart::Text {
text: "done".to_owned(),
},
],
});
assert_eq!(updates.len(), 2);
assert!(matches!(
updates[0],
acp::schema::v1::SessionUpdate::ToolCall(_)
));
assert!(matches!(
updates[1],
acp::schema::v1::SessionUpdate::AgentMessageChunk(_)
));
}
#[test]
fn tool_result_becomes_tool_call_update_with_status() {
let updates = session_event_to_updates(zeph_session::SessionEvent::ToolResult {
id: "call_0".to_owned(),
name: "shell".to_owned(),
output: "ok".to_owned(),
is_error: false,
duration_ms: 10,
});
assert_eq!(updates.len(), 1);
let acp::schema::v1::SessionUpdate::ToolCallUpdate(update) = &updates[0] else {
panic!("expected ToolCallUpdate");
};
assert_eq!(
update.fields.status,
Some(acp::schema::v1::ToolCallStatus::Completed)
);
}
#[test]
fn failed_tool_result_maps_to_failed_status() {
let updates = session_event_to_updates(zeph_session::SessionEvent::ToolResult {
id: "call_0".to_owned(),
name: "shell".to_owned(),
output: "boom".to_owned(),
is_error: true,
duration_ms: 10,
});
let acp::schema::v1::SessionUpdate::ToolCallUpdate(update) = &updates[0] else {
panic!("expected ToolCallUpdate");
};
assert_eq!(
update.fields.status,
Some(acp::schema::v1::ToolCallStatus::Failed)
);
}
#[test]
fn bookkeeping_events_produce_no_client_visible_update() {
assert!(
session_event_to_updates(zeph_session::SessionEvent::SessionStarted {
session_id: "s1".to_owned(),
cwd: "/tmp".to_owned(),
provider_name: "claude".to_owned(),
model: "opus".to_owned(),
forked_from: None,
})
.is_empty()
);
assert!(
session_event_to_updates(zeph_session::SessionEvent::ForkPoint {
new_session_id: "s2".to_owned(),
})
.is_empty()
);
assert!(
session_event_to_updates(zeph_session::SessionEvent::SessionEnded {
reason: "user_quit".to_owned(),
})
.is_empty()
);
}
}