1#[cfg(feature = "unstable-llm-providers")]
15use std::collections::{HashMap, HashSet};
16use std::path::PathBuf;
17use std::pin::Pin;
18use std::sync::Arc;
19use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
20
21use parking_lot::{Mutex, RwLock};
22
23use agent_client_protocol as acp;
24use tokio::sync::{mpsc, oneshot};
25use tokio::task::JoinHandle;
26use tokio_util::sync::CancellationToken;
27use zeph_common::task_supervisor::TaskSupervisor;
28use zeph_core::channel::{ChannelMessage, LoopbackChannel};
29use zeph_core::{ContentSanitizer, LoopbackEvent, StopHint};
30use zeph_llm::any::AnyProvider;
31use zeph_mcp::McpManager;
32use zeph_memory::ConversationId;
33use zeph_memory::store::SqliteStore;
34
35use crate::fs::AcpFileExecutor;
36use crate::lsp::DiagnosticsCache;
37use crate::permission::AcpPermissionGate;
38use crate::terminal::AcpShellExecutor;
39use crate::transport::SharedAvailableModels;
40
41pub type ProviderFactory = Arc<dyn Fn(&str) -> Option<AnyProvider> + Send + Sync>;
59
60pub struct SessionContext {
70 pub session_id: acp::schema::v1::SessionId,
72 pub conversation_id: Option<ConversationId>,
74 pub working_dir: PathBuf,
76}
77
78pub struct AcpContext {
84 pub file_executor: Option<AcpFileExecutor>,
88 pub shell_executor: Option<AcpShellExecutor>,
92 pub permission_gate: Option<AcpPermissionGate>,
96 pub cancel_signal: std::sync::Arc<tokio::sync::Notify>,
100 pub provider_override: Arc<RwLock<Option<AnyProvider>>>,
104 pub parent_tool_use_id: Option<String>,
108 pub lsp_provider: Option<crate::lsp::AcpLspProvider>,
112 pub diagnostics_cache: Arc<RwLock<DiagnosticsCache>>,
115 pub status_notifier: SessionStatusNotifier,
119 #[cfg(feature = "unstable-elicitation")]
124 #[allow(dead_code)]
125 pub(crate) elicitation_bridge: Option<elicitation::ElicitationBridge>,
126}
127
128pub type AgentSpawner = Arc<
153 dyn Fn(
154 LoopbackChannel,
155 Option<AcpContext>,
156 SessionContext,
157 ) -> Pin<Box<dyn std::future::Future<Output = ()> + 'static>>
158 + Send
159 + Sync
160 + 'static,
161>;
162
163#[cfg(feature = "acp-http")]
169#[cfg_attr(docsrs, doc(cfg(feature = "acp-http")))]
170pub type SendAgentSpawner = AgentSpawner;
171
172pub type NotifySender = mpsc::Sender<(acp::schema::v1::SessionNotification, oneshot::Sender<()>)>;
178
179pub(crate) type NotifyReceiver =
181 mpsc::Receiver<(acp::schema::v1::SessionNotification, oneshot::Sender<()>)>;
182
183#[derive(Clone)]
197pub struct SessionStatusNotifier {
198 notify_tx: NotifySender,
199 session_id: acp::schema::v1::SessionId,
200}
201
202impl SessionStatusNotifier {
203 #[must_use]
224 pub fn new(notify_tx: NotifySender, session_id: acp::schema::v1::SessionId) -> Self {
225 Self {
226 notify_tx,
227 session_id,
228 }
229 }
230
231 pub fn notify_status_nowait(&self, text: impl Into<String>) {
238 let text = text.into();
239 if text.is_empty() {
240 return;
241 }
242 let update = acp::schema::v1::SessionUpdate::AgentThoughtChunk(
243 acp::schema::v1::ContentChunk::new(text.into()),
244 );
245 let notification =
246 acp::schema::v1::SessionNotification::new(self.session_id.clone(), update);
247 let (ack_tx, _) = oneshot::channel();
248 if let Err(e) = self.notify_tx.try_send((notification, ack_tx)) {
249 tracing::warn!(
250 error = %e,
251 "proactive session status notification dropped: channel full or closed"
252 );
253 }
254 }
255}
256
257pub(crate) struct SessionConfigSeed {
262 thinking_enabled: bool,
263 auto_approve_level: String,
264 temperature_preset: zeph_config::AcpTemperaturePreset,
265}
266
267static SESSION_ENTRY_GENERATION: AtomicU64 = AtomicU64::new(0);
280
281pub(crate) struct SessionEntry {
282 pub(crate) input_tx: mpsc::Sender<ChannelMessage>,
283 pub(crate) output_rx: Mutex<Option<mpsc::Receiver<LoopbackEvent>>>,
286 pub(crate) generation: u64,
289 pub(crate) cancel_signal: Arc<tokio::sync::Notify>,
290 pub(crate) last_active_ms: AtomicU64,
292 pub(crate) created_at: chrono::DateTime<chrono::Utc>,
293 pub(crate) working_dir: Mutex<Option<std::path::PathBuf>>,
294 pub(crate) notify_tx: NotifySender,
296 pub(crate) notify_rx: Mutex<Option<NotifyReceiver>>,
299 provider_override: Arc<RwLock<Option<AnyProvider>>>,
301 current_model: Mutex<String>,
303 current_mode: Mutex<acp::schema::v1::SessionModeId>,
305 first_prompt_done: AtomicBool,
307 title: Mutex<Option<String>>,
309 thinking_enabled: AtomicBool,
311 auto_approve_level: Mutex<String>,
313 temperature_preset: Mutex<zeph_config::AcpTemperaturePreset>,
316 pub(crate) shell_executor: Option<AcpShellExecutor>,
320 pub(crate) agent_loop_handle: Mutex<Option<JoinHandle<()>>>,
334 #[cfg(feature = "unstable-elicitation")]
339 pub(crate) elicitation_bridge_handle: Option<JoinHandle<()>>,
340 #[cfg(feature = "unstable-session-usage")]
342 pub(crate) usage_accumulator: Mutex<SessionUsageAccumulator>,
343}
344
345impl Drop for SessionEntry {
346 fn drop(&mut self) {
347 if let Some(handle) = self.agent_loop_handle.lock().take() {
348 handle.abort();
349 }
350 #[cfg(feature = "unstable-elicitation")]
351 if let Some(handle) = self.elicitation_bridge_handle.take() {
352 handle.abort();
353 }
354 }
355}
356
357impl SessionEntry {
358 #[allow(dead_code)]
359 fn last_active(&self) -> std::time::Instant {
360 let ms = self.last_active_ms.load(Ordering::Relaxed);
361 let now_ms = u64::try_from(
362 std::time::SystemTime::now()
363 .duration_since(std::time::UNIX_EPOCH)
364 .unwrap_or_default()
365 .as_millis(),
366 )
367 .unwrap_or(u64::MAX);
368 let elapsed_ms = now_ms.saturating_sub(ms);
369 std::time::Instant::now()
370 .checked_sub(std::time::Duration::from_millis(elapsed_ms))
371 .unwrap_or_else(std::time::Instant::now)
372 }
373
374 fn touch(&self) {
375 let ms = u64::try_from(
376 std::time::SystemTime::now()
377 .duration_since(std::time::UNIX_EPOCH)
378 .unwrap_or_default()
379 .as_millis(),
380 )
381 .unwrap_or(u64::MAX);
382 self.last_active_ms.store(ms, Ordering::Relaxed);
383 }
384}
385
386type SessionMap = Arc<Mutex<std::collections::HashMap<acp::schema::v1::SessionId, SessionEntry>>>;
387
388pub struct ZephAcpAgentState {
394 pub(crate) spawner: AgentSpawner,
395 pub(crate) sessions: SessionMap,
396 pub(crate) agent_name: String,
397 agent_version: String,
398 max_sessions: usize,
399 idle_timeout: std::time::Duration,
400 pub(crate) store: Option<SqliteStore>,
401 pub(crate) session_data_dir: Option<std::path::PathBuf>,
404 permission_file: Option<std::path::PathBuf>,
405 pub(crate) client_caps: RwLock<acp::schema::v1::ClientCapabilities>,
407 pub(crate) provider_factory: Option<ProviderFactory>,
409 available_models: SharedAvailableModels,
411 pub(crate) mcp_manager: Option<Arc<McpManager>>,
413 project_rules: Vec<std::path::PathBuf>,
415 title_max_chars: usize,
417 max_history: usize,
419 pub(crate) lsp_config: zeph_core::config::AcpLspConfig,
421 pub(crate) diagnostics_cache: Arc<RwLock<DiagnosticsCache>>,
423 reaper_cancel: CancellationToken,
425 task_supervisor: TaskSupervisor,
427 additional_directories_allow: Vec<std::path::PathBuf>,
429 auth_methods_config: Vec<zeph_core::config::AcpAuthMethod>,
431 pub(crate) timeouts: zeph_config::AcpTimeoutsConfig,
433 pub(crate) model_config: zeph_config::AcpModelConfigConfig,
435 prompt_injection_detector: ContentSanitizer,
441 #[cfg(feature = "unstable-elicitation")]
443 pub(crate) elicitation_supported: std::sync::atomic::AtomicBool,
444 #[cfg(feature = "unstable-llm-providers")]
449 pub(crate) provider_names: Vec<(String, agent_client_protocol_schema::v1::LlmProtocol)>,
450 #[cfg(feature = "unstable-llm-providers")]
452 pub(crate) global_disabled_providers: Mutex<HashSet<String>>,
453 #[cfg(feature = "unstable-llm-providers")]
455 pub(crate) global_provider_overrides: Mutex<HashMap<String, ProviderSetOverride>>,
456 pub(crate) owner_key: String,
460}
461
462pub type ZephAcpAgent = ZephAcpAgentState;
464
465impl ZephAcpAgentState {
466 #[cfg(feature = "unstable-cancel-request")]
471 pub(crate) fn session_cancel_signal(
472 &self,
473 session_id: &acp::schema::v1::SessionId,
474 ) -> Option<Arc<tokio::sync::Notify>> {
475 self.sessions
476 .lock()
477 .get(session_id)
478 .map(|entry| Arc::clone(&entry.cancel_signal))
479 }
480
481 #[allow(clippy::too_many_arguments)] pub(crate) async fn build_acp_context(
483 &self,
484 session_id: &acp::schema::v1::SessionId,
485 cx: &acp::ConnectionTo<acp::Client>,
486 cancel_signal: Arc<tokio::sync::Notify>,
487 provider_override: Arc<RwLock<Option<AnyProvider>>>,
488 cwd: PathBuf,
489 notify_tx: NotifySender,
490 #[cfg(feature = "unstable-elicitation")] elicitation_tx: Option<
491 elicitation::ElicitationSender,
492 >,
493 ) -> AcpContext {
494 let (can_read, can_write, ide_supports_lsp) = {
496 let caps = self.client_caps.read();
497 let r = caps.fs.read_text_file;
498 let w = caps.fs.write_text_file;
499 let lsp = self.lsp_config.enabled
500 && caps.meta.as_ref().is_some_and(|m| m.contains_key("lsp"));
501 (r, w, lsp)
502 };
503
504 let conn = Arc::new(cx.clone());
505
506 let (perm_gate, perm_handler) =
507 AcpPermissionGate::new(Arc::clone(&conn), self.permission_file.clone());
508 tokio::spawn(perm_handler);
512
513 let (fs_exec, fs_handler) = AcpFileExecutor::new(
514 Arc::clone(&conn),
515 session_id.clone(),
516 can_read,
517 can_write,
518 cwd,
519 Some(perm_gate.clone()),
520 )
521 .await;
522 tokio::spawn(fs_handler);
524
525 let (shell_exec, shell_handler) = AcpShellExecutor::new(
526 Arc::clone(&conn),
527 session_id.clone(),
528 Some(perm_gate.clone()),
529 self.timeouts.terminal_secs,
530 );
531 tokio::spawn(shell_handler);
533
534 let lsp_provider = if ide_supports_lsp {
535 let (provider, lsp_handler) = crate::lsp::AcpLspProvider::new(
536 Arc::clone(&conn),
537 true,
538 self.lsp_config.request_timeout_secs,
539 self.lsp_config.max_references,
540 self.lsp_config.max_workspace_symbols,
541 );
542 tokio::spawn(lsp_handler);
544 Some(provider)
545 } else {
546 None
547 };
548
549 AcpContext {
550 file_executor: Some(fs_exec),
551 shell_executor: Some(shell_exec),
552 permission_gate: Some(perm_gate),
553 cancel_signal,
554 provider_override,
555 parent_tool_use_id: None,
556 lsp_provider,
557 diagnostics_cache: Arc::clone(&self.diagnostics_cache),
558 status_notifier: SessionStatusNotifier::new(notify_tx, session_id.clone()),
559 #[cfg(feature = "unstable-elicitation")]
560 elicitation_bridge: elicitation_tx.map(|tx| elicitation::ElicitationBridge {
561 tx,
562 timeout_secs: self.timeouts.elicitation_secs,
563 }),
564 }
565 }
566
567 pub(crate) async fn send_notification(
568 &self,
569 session_id: &acp::schema::v1::SessionId,
570 notification: acp::schema::v1::SessionNotification,
571 ) -> acp::Result<()> {
572 let tx = self
573 .sessions
574 .lock()
575 .get(session_id)
576 .map(|e| e.notify_tx.clone());
577 let Some(tx) = tx else {
578 return Err(acp::Error::internal_error().data("session not found"));
579 };
580 let (ack_tx, ack_rx) = oneshot::channel();
581 tx.send((notification, ack_tx))
582 .await
583 .map_err(|_| acp::Error::internal_error().data("notification channel closed"))?;
584 let timeout = std::time::Duration::from_millis(self.timeouts.notify_ack_timeout_ms);
585 tokio::time::timeout(timeout, ack_rx)
586 .await
587 .map_err(|_| {
588 tracing::warn!(
589 timeout_ms = self.timeouts.notify_ack_timeout_ms,
590 "notification ack timed out — IDE client may be hung"
591 );
592 acp::Error::internal_error().data("notification ack timed out")
593 })?
594 .map_err(|_| acp::Error::internal_error().data("notification ack lost"))
595 }
596
597 pub(crate) fn send_notification_nowait(
599 &self,
600 session_id: &acp::schema::v1::SessionId,
601 notification: acp::schema::v1::SessionNotification,
602 ) {
603 let tx = self
604 .sessions
605 .lock()
606 .get(session_id)
607 .map(|e| e.notify_tx.clone());
608 if let Some(tx) = tx {
609 let (ack_tx, _) = oneshot::channel();
610 if let Err(e) = tx.try_send((notification, ack_tx)) {
611 tracing::warn!(error = %e, "session notification dropped: channel full or closed");
612 }
613 }
614 }
615}
616
617impl ZephAcpAgentState {
619 #[allow(clippy::unused_async)]
620 #[tracing::instrument(skip_all, name = "acp.handler.initialize")]
621 pub(crate) async fn do_initialize(
622 &self,
623 args: acp::schema::v1::InitializeRequest,
624 ) -> acp::Result<acp::schema::v1::InitializeResponse> {
625 tracing::debug!("ACP initialize");
626 #[cfg(feature = "unstable-elicitation")]
627 {
628 let supports = args.client_capabilities.elicitation.is_some();
629 self.elicitation_supported
630 .store(supports, std::sync::atomic::Ordering::Relaxed);
631 tracing::debug!(
632 elicitation_supported = supports,
633 "ACP initialize: elicitation capability"
634 );
635 }
636 *self.client_caps.write() = args.client_capabilities;
637 let title = format!("{} AI Agent", self.agent_name);
638
639 let mut meta = serde_json::Map::new();
642 meta.insert(
643 "auth_hint".to_owned(),
644 serde_json::json!("authentication required"),
645 );
646
647 let mut caps = acp::schema::v1::AgentCapabilities::new()
648 .load_session(true)
649 .prompt_capabilities(
650 acp::schema::v1::PromptCapabilities::new()
651 .image(true)
652 .embedded_context(true),
653 )
654 .meta({
655 let mut cap_meta = serde_json::Map::new();
656 cap_meta.insert("config_options".to_owned(), serde_json::json!(true));
657 cap_meta.insert("ext_methods".to_owned(), serde_json::json!(true));
658 if self.lsp_config.enabled {
659 cap_meta.insert(
660 "lsp".to_owned(),
661 serde_json::json!({
662 "methods": crate::lsp::LSP_METHODS,
663 "notifications": crate::lsp::LSP_NOTIFICATIONS,
664 }),
665 );
666 }
667 cap_meta
668 });
669 if self.mcp_manager.is_some() {
672 caps = caps.mcp_capabilities(
673 acp::schema::v1::McpCapabilities::new()
674 .http(true)
675 .sse(false),
676 );
677 }
678 #[cfg(any(
679 feature = "unstable-session-delete",
680 feature = "unstable-session-fork",
681 feature = "unstable-session-resume",
682 ))]
683 let caps = {
684 let mut session_caps = acp::schema::v1::SessionCapabilities::new();
685 session_caps = session_caps.list(acp::schema::v1::SessionListCapabilities::default());
686 {
687 session_caps =
688 session_caps.close(acp::schema::v1::SessionCloseCapabilities::default());
689 }
690 #[cfg(feature = "unstable-session-fork")]
691 {
692 session_caps =
693 session_caps.fork(acp::schema::v1::SessionForkCapabilities::default());
694 }
695 {
696 session_caps =
697 session_caps.resume(acp::schema::v1::SessionResumeCapabilities::default());
698 }
699 caps.session_capabilities(session_caps)
700 };
701
702 let caps = caps.auth(
703 acp::schema::v1::AgentAuthCapabilities::default()
704 .logout(acp::schema::v1::LogoutCapabilities::default()),
705 );
706
707 let auth_methods: Vec<acp::schema::v1::AuthMethod> = self
708 .auth_methods_config
709 .iter()
710 .map(|_m| {
711 acp::schema::v1::AuthMethod::Agent(acp::schema::v1::AuthMethodAgent::new(
712 "zeph", "Zeph",
713 ))
714 })
715 .collect();
716
717 Ok(
718 acp::schema::v1::InitializeResponse::new(acp::schema::ProtocolVersion::LATEST)
719 .auth_methods(auth_methods)
720 .agent_info(
721 acp::schema::v1::Implementation::new(&self.agent_name, &self.agent_version)
722 .title(title),
723 )
724 .agent_capabilities(caps)
725 .meta(meta),
726 )
727 }
728
729 #[tracing::instrument(skip_all, name = "acp.handler.dispatch")]
730 pub(crate) async fn do_ext_method(
731 &self,
732 args: acp::schema::v1::ExtRequest,
733 ) -> acp::Result<acp::schema::v1::ExtResponse> {
734 if let Some(fut) = crate::custom::dispatch(self, &args) {
735 return fut.await;
736 }
737 #[cfg(feature = "unstable-llm-providers")]
738 {
739 if let Some(resp) = self.ext_method_providers(&args)? {
740 return Ok(resp);
741 }
742 }
743 self.ext_method_mcp(&args).await
744 }
745
746 pub(crate) async fn do_ext_notification(
747 &self,
748 args: acp::schema::v1::ExtNotification,
749 cx: &acp::ConnectionTo<acp::Client>,
750 ) -> acp::Result<()> {
751 tracing::debug!(method = %args.method, "received ext_notification");
752 match args.method.as_ref() {
753 "lsp/publishDiagnostics" => {
754 self.handle_lsp_publish_diagnostics(args.params.get());
755 }
756 "lsp/didSave" => {
757 self.handle_lsp_did_save(args.params.get(), cx).await;
758 }
759 _ => {}
760 }
761 Ok(())
762 }
763
764 #[allow(clippy::unused_async)]
765 #[tracing::instrument(skip_all, name = "acp.handler.authenticate")]
766 pub(crate) async fn do_authenticate(
767 &self,
768 _args: acp::schema::v1::AuthenticateRequest,
769 ) -> acp::Result<acp::schema::v1::AuthenticateResponse> {
770 Ok(acp::schema::v1::AuthenticateResponse::default())
771 }
772
773 #[allow(clippy::unused_async)]
774 #[tracing::instrument(skip_all, name = "acp.handler.logout")]
775 pub(crate) async fn do_logout(
776 &self,
777 _args: acp::schema::v1::LogoutRequest,
778 ) -> acp::Result<acp::schema::v1::LogoutResponse> {
779 tracing::debug!("ACP logout (no-op: vault-based auth)");
780 Ok(acp::schema::v1::LogoutResponse::default())
781 }
782
783 #[allow(clippy::unused_async)]
784 #[tracing::instrument(skip_all, name = "acp.handler.cancel", fields(session_id = %args.session_id))]
785 pub(crate) async fn do_cancel(
786 &self,
787 args: acp::schema::v1::CancelNotification,
788 ) -> acp::Result<()> {
789 tracing::debug!(session_id = %args.session_id, "ACP cancel");
790 if let Some(entry) = self.sessions.lock().get(&args.session_id) {
791 entry.cancel_signal.notify_one();
792 }
793 Ok(())
794 }
795}
796
797fn session_event_to_updates(
809 event: zeph_session::SessionEvent,
810) -> Vec<acp::schema::v1::SessionUpdate> {
811 match event {
812 zeph_session::SessionEvent::UserMessage { text, .. } => {
813 vec![acp::schema::v1::SessionUpdate::UserMessageChunk(
814 acp::schema::v1::ContentChunk::new(text.into()),
815 )]
816 }
817 zeph_session::SessionEvent::AssistantMessage { parts } => parts
818 .into_iter()
819 .filter_map(|part| match part {
820 zeph_llm::provider::MessagePart::ToolUse { id, name, input } => {
821 Some(acp::schema::v1::SessionUpdate::ToolCall(
822 acp::schema::v1::ToolCall::new(id, name).raw_input(input),
823 ))
824 }
825 other => other.as_plain_text().map(|text| {
826 acp::schema::v1::SessionUpdate::AgentMessageChunk(
827 acp::schema::v1::ContentChunk::new(text.to_owned().into()),
828 )
829 }),
830 })
831 .collect(),
832 zeph_session::SessionEvent::ToolCall { id, name, input } => {
833 vec![acp::schema::v1::SessionUpdate::ToolCall(
834 acp::schema::v1::ToolCall::new(id, name).raw_input(input),
835 )]
836 }
837 zeph_session::SessionEvent::ToolResult {
838 id,
839 output,
840 is_error,
841 ..
842 } => {
843 let status = if is_error {
844 acp::schema::v1::ToolCallStatus::Failed
845 } else {
846 acp::schema::v1::ToolCallStatus::Completed
847 };
848 vec![acp::schema::v1::SessionUpdate::ToolCallUpdate(
849 acp::schema::v1::ToolCallUpdate::new(
850 id,
851 acp::schema::v1::ToolCallUpdateFields::new()
852 .status(status)
853 .content(vec![output.into()]),
854 ),
855 )]
856 }
857 zeph_session::SessionEvent::SessionStarted { .. }
858 | zeph_session::SessionEvent::ForkPoint { .. }
859 | zeph_session::SessionEvent::Condensation { .. }
860 | zeph_session::SessionEvent::Compaction { .. }
861 | zeph_session::SessionEvent::ModelChanged { .. }
862 | zeph_session::SessionEvent::SessionEnded { .. } => Vec::new(),
863 }
864}
865
866fn is_acp_native_slash_command(trimmed_text: &str) -> bool {
874 trimmed_text == "/help"
875 || trimmed_text.starts_with("/help ")
876 || trimmed_text == "/mode"
877 || trimmed_text.starts_with("/mode ")
878 || trimmed_text == "/clear"
879 || trimmed_text == "/model"
880 || trimmed_text.starts_with("/model ")
881}
882
883pub async fn warm_model_caches(
897 provider: zeph_llm::any::AnyProvider,
898 available_models: SharedAvailableModels,
899) -> usize {
900 use zeph_llm::model_cache::ModelCache;
901
902 let provider_count = {
903 let models = available_models.read();
904 models
905 .iter()
906 .filter_map(|k| k.split_once(':').map(|(slug, _)| slug))
907 .collect::<std::collections::HashSet<_>>()
908 .len()
909 };
910 tracing::info!(
911 providers = provider_count,
912 "warming model caches in background"
913 );
914
915 let fetch = async move {
916 match provider.list_models_remote().await {
917 Ok(models) => {
918 let count = models.len();
919 tracing::info!(models = count, "model cache fetch completed");
920 count
921 }
922 Err(e) => {
923 tracing::info!(error = %e, "model cache warm-up failed; keeping fallback list");
924 0
925 }
926 }
927 };
928
929 let Ok(fetched) = tokio::time::timeout(std::time::Duration::from_secs(5), fetch).await else {
930 tracing::info!("model cache warm-up timed out; keeping fallback list");
931 return 0;
932 };
933
934 let slugs: Vec<String> = {
936 let models = available_models.read();
937 models
938 .iter()
939 .filter_map(|k| k.split_once(':').map(|(s, _)| s.to_owned()))
940 .collect::<std::collections::HashSet<_>>()
941 .into_iter()
942 .collect()
943 };
944
945 for slug in slugs {
946 let cache = ModelCache::for_slug(&slug);
947 if cache.is_stale_async().await {
948 tracing::info!(provider = %slug, "model cache still stale after warm-up");
949 continue;
950 }
951 if let Ok(Some(entries)) = cache.load_async().await
952 && !entries.is_empty()
953 {
954 let new_keys: Vec<String> = entries
955 .into_iter()
956 .map(|m| format!("{slug}:{}", m.id))
957 .collect();
958 let count = new_keys.len();
959 let mut models = available_models.write();
960 models.retain(|k| !k.starts_with(&format!("{slug}:")));
961 models.extend(new_keys);
962 models.dedup();
963 tracing::info!(provider = %slug, models = count, "model cache ready");
964 }
965 }
966 let total_models = available_models.read().len();
967 tracing::info!(models = total_models, "model cache warming finished");
968 fetched
969}
970
971fn compute_stop_reason(
973 cancelled: bool,
974 stop_hint: Option<StopHint>,
975) -> acp::schema::v1::StopReason {
976 if cancelled {
977 acp::schema::v1::StopReason::Cancelled
978 } else {
979 match stop_hint {
980 Some(StopHint::MaxTokens) => acp::schema::v1::StopReason::MaxTokens,
981 Some(StopHint::MaxTurnRequests) => acp::schema::v1::StopReason::MaxTurnRequests,
982 None | Some(_) => acp::schema::v1::StopReason::EndTurn,
983 }
984 }
985}
986
987fn build_prompt_response(
990 stop_reason: acp::schema::v1::StopReason,
991 #[cfg(feature = "unstable-session-usage")] turn_usage: TurnUsage,
992) -> acp::schema::v1::PromptResponse {
993 let r = acp::schema::v1::PromptResponse::new(stop_reason);
994 #[cfg(feature = "unstable-session-usage")]
995 let r = {
996 let total = turn_usage
997 .input_tokens
998 .saturating_add(turn_usage.output_tokens);
999 let usage =
1000 acp::schema::v1::Usage::new(total, turn_usage.input_tokens, turn_usage.output_tokens)
1001 .cached_read_tokens(
1003 (turn_usage.cache_read_tokens > 0).then_some(turn_usage.cache_read_tokens),
1004 )
1005 .cached_write_tokens(
1006 (turn_usage.cache_write_tokens > 0).then_some(turn_usage.cache_write_tokens),
1007 );
1008 r.usage(usage)
1009 };
1010 r
1011}
1012
1013#[cfg(feature = "unstable-elicitation")]
1014pub(crate) mod elicitation;
1015#[cfg(feature = "unstable-llm-providers")]
1016mod providers;
1017#[cfg(feature = "unstable-llm-providers")]
1018pub(crate) use providers::ProviderSetOverride;
1019#[cfg(feature = "unstable-session-usage")]
1020mod usage;
1021#[cfg(feature = "unstable-session-usage")]
1022pub(crate) use usage::{SessionUsageAccumulator, TurnUsage};
1023pub(super) mod helpers;
1024use helpers::{
1025 DEFAULT_MODE_ID, DIAGNOSTICS_MIME_TYPE, build_available_commands, build_config_options,
1026 build_mode_state, format_diagnostics_block, loopback_event_to_updates, mime_to_ext, model_meta,
1027};
1028use zeph_common::text::xml_escape;
1029
1030pub(crate) mod handlers;
1031
1032mod builder;
1033mod lsp_events;
1034mod mcp_ext;
1035mod model;
1036mod reaper;
1037mod session;
1038mod slash;
1039mod turn;
1040
1041macro_rules! req_handler {
1046 ($state:expr, $handler:path) => {{
1047 let s = Arc::clone(&$state);
1048 move |req, responder, cx| {
1049 let s = Arc::clone(&s);
1050 async move { $handler(req, responder, cx, s).await }
1051 }
1052 }};
1053}
1054
1055macro_rules! notif_handler {
1057 ($state:expr, $handler:path) => {{
1058 let s = Arc::clone(&$state);
1059 move |notif, cx| {
1060 let s = Arc::clone(&s);
1061 async move { $handler(notif, cx, s).await }
1062 }
1063 }};
1064}
1065
1066#[allow(clippy::too_many_lines)]
1098pub async fn run_agent(
1099 state: Arc<ZephAcpAgentState>,
1100 transport: impl acp::ConnectTo<acp::Agent>,
1101) -> acp::Result<()> {
1102 #[cfg(feature = "unstable-session-fork")]
1103 use handlers::fork_session;
1104 use handlers::{
1105 authenticate, cancel, close_session, delete_session, dispatch, initialize, list_sessions,
1106 load_session, logout, new_session, prompt, resume_session, set_session_config_option,
1107 set_session_mode,
1108 };
1109
1110 let builder = acp::Agent
1111 .builder()
1112 .on_receive_request(
1113 req_handler!(state, initialize::handle_initialize),
1114 acp::on_receive_request!(),
1115 )
1116 .on_receive_request(
1117 req_handler!(state, authenticate::handle_authenticate),
1118 acp::on_receive_request!(),
1119 )
1120 .on_receive_request(
1121 req_handler!(state, new_session::handle_new_session),
1122 acp::on_receive_request!(),
1123 )
1124 .on_receive_request(
1125 req_handler!(state, prompt::handle_prompt),
1126 acp::on_receive_request!(),
1127 )
1128 .on_receive_request(
1129 req_handler!(state, list_sessions::handle_list_sessions),
1130 acp::on_receive_request!(),
1131 )
1132 .on_receive_request(
1133 req_handler!(state, load_session::handle_load_session),
1134 acp::on_receive_request!(),
1135 )
1136 .on_receive_request(
1137 req_handler!(
1138 state,
1139 set_session_config_option::handle_set_session_config_option
1140 ),
1141 acp::on_receive_request!(),
1142 )
1143 .on_receive_request(
1144 req_handler!(state, set_session_mode::handle_set_session_mode),
1145 acp::on_receive_request!(),
1146 )
1147 .on_receive_notification(
1148 notif_handler!(state, cancel::handle_cancel),
1149 acp::on_receive_notification!(),
1150 );
1151
1152 let builder = builder.on_receive_request(
1153 req_handler!(state, close_session::handle_close_session),
1154 acp::on_receive_request!(),
1155 );
1156 let builder = builder.on_receive_request(
1157 req_handler!(state, delete_session::handle_delete_session),
1158 acp::on_receive_request!(),
1159 );
1160 #[cfg(feature = "unstable-session-fork")]
1161 let builder = builder.on_receive_request(
1162 req_handler!(state, fork_session::handle_fork_session),
1163 acp::on_receive_request!(),
1164 );
1165 let builder = builder.on_receive_request(
1166 req_handler!(state, resume_session::handle_resume_session),
1167 acp::on_receive_request!(),
1168 );
1169 let builder = builder.on_receive_request(
1170 req_handler!(state, logout::handle_logout),
1171 acp::on_receive_request!(),
1172 );
1173 #[cfg(feature = "unstable-cancel-request")]
1174 let builder = builder.on_receive_notification(
1175 notif_handler!(state, handlers::cancel_request::handle_cancel_request),
1176 acp::on_receive_notification!(),
1177 );
1178
1179 builder
1180 .on_receive_dispatch(
1181 {
1182 let s = Arc::clone(&state);
1183 move |msg, cx| {
1184 let s = Arc::clone(&s);
1185 async move { dispatch::handle_dispatch(msg, cx, s).await }
1186 }
1187 },
1188 acp::on_receive_dispatch!(),
1189 )
1190 .connect_to(transport)
1191 .await
1192}
1193
1194const _: () = {
1196 #[allow(clippy::used_underscore_items)]
1197 fn assert_send_sync<T: Send + Sync>() {}
1198 fn check_send_sync() {
1199 assert_send_sync::<ZephAcpAgentState>();
1200 assert_send_sync::<crate::fs::AcpFileExecutor>();
1201 assert_send_sync::<crate::terminal::AcpShellExecutor>();
1202 assert_send_sync::<crate::permission::AcpPermissionGate>();
1203 }
1204 let _ = check_send_sync;
1205};
1206
1207#[cfg(test)]
1209mod notify_timeout_tests {
1210 use std::sync::Arc;
1211
1212 use parking_lot::RwLock;
1213 use zeph_core::channel::LoopbackChannel;
1214 use zeph_llm::any::AnyProvider;
1215
1216 use super::*;
1217
1218 fn make_agent_for_timeout() -> ZephAcpAgent {
1219 let spawner: AgentSpawner = Arc::new(|_ch, _ctx, _sc| Box::pin(async {}));
1220 let mut agent = ZephAcpAgent::new(spawner, 4, 1800, None);
1221 agent.timeouts.notify_ack_timeout_ms = 50;
1223 agent
1224 }
1225
1226 #[tokio::test]
1229 async fn send_notification_returns_error_when_ack_times_out() {
1230 let agent = make_agent_for_timeout();
1231 let session_id = acp::schema::v1::SessionId::new("timeout-test".to_owned());
1232
1233 let (_, handle) = LoopbackChannel::pair(4);
1234 let provider_override = Arc::new(RwLock::new(None::<AnyProvider>));
1235 let (notify_tx, notify_rx) = mpsc::channel(256);
1236 let entry = ZephAcpAgent::make_session_entry(
1237 handle,
1238 "test-model".to_owned(),
1239 std::path::PathBuf::from("."),
1240 None,
1241 provider_override,
1242 SessionConfigSeed {
1243 thinking_enabled: false,
1244 auto_approve_level: "suggest".to_owned(),
1245 temperature_preset: zeph_config::AcpTemperaturePreset::default(),
1246 },
1247 notify_tx,
1248 notify_rx,
1249 );
1250 agent.sessions.lock().insert(session_id.clone(), entry);
1252
1253 let update = acp::schema::v1::SessionUpdate::AgentMessageChunk(
1254 acp::schema::v1::ContentChunk::new("hello".into()),
1255 );
1256 let notif = acp::schema::v1::SessionNotification::new(session_id.clone(), update);
1257 let result = agent.send_notification(&session_id, notif).await;
1258 assert!(
1259 result.is_err(),
1260 "send_notification must fail when ack does not arrive within the timeout"
1261 );
1262 }
1263}
1264
1265#[cfg(test)]
1268mod session_status_notifier_tests {
1269 use super::*;
1270
1271 #[tokio::test]
1272 async fn notify_status_nowait_delivers_agent_thought_chunk_immediately() {
1273 let (notify_tx, mut notify_rx) = mpsc::channel(4);
1274 let session_id = acp::schema::v1::SessionId::new("notifier-test".to_owned());
1275 let notifier = SessionStatusNotifier::new(notify_tx, session_id.clone());
1276
1277 notifier.notify_status_nowait("degraded");
1278
1279 let (notification, _ack) = notify_rx.try_recv().expect(
1280 "notify_status_nowait must push onto the channel synchronously, without a drainer",
1281 );
1282 assert_eq!(notification.session_id, session_id);
1283 match notification.update {
1284 acp::schema::v1::SessionUpdate::AgentThoughtChunk(chunk) => match chunk.content {
1285 acp::schema::v1::ContentBlock::Text(t) => assert_eq!(t.text, "degraded"),
1286 other => panic!("expected ContentBlock::Text, got {other:?}"),
1287 },
1288 other => panic!("expected AgentThoughtChunk, got {other:?}"),
1289 }
1290 }
1291
1292 #[tokio::test]
1295 async fn notify_status_nowait_skips_empty_text() {
1296 let (notify_tx, mut notify_rx) = mpsc::channel(4);
1297 let session_id = acp::schema::v1::SessionId::new("notifier-empty-test".to_owned());
1298 let notifier = SessionStatusNotifier::new(notify_tx, session_id);
1299
1300 notifier.notify_status_nowait("");
1301
1302 assert!(notify_rx.try_recv().is_err(), "empty text must not be sent");
1303 }
1304}
1305
1306#[cfg(test)]
1311mod session_event_replay_tests {
1312 use super::*;
1313
1314 #[test]
1315 fn user_message_becomes_user_message_chunk() {
1316 let updates = session_event_to_updates(zeph_session::SessionEvent::UserMessage {
1317 text: "hello".to_owned(),
1318 image_refs: Vec::new(),
1319 });
1320 assert_eq!(updates.len(), 1);
1321 assert!(matches!(
1322 updates[0],
1323 acp::schema::v1::SessionUpdate::UserMessageChunk(_)
1324 ));
1325 }
1326
1327 #[test]
1328 fn assistant_text_part_becomes_agent_message_chunk() {
1329 let updates = session_event_to_updates(zeph_session::SessionEvent::AssistantMessage {
1330 parts: vec![zeph_llm::provider::MessagePart::Text {
1331 text: "hi there".to_owned(),
1332 }],
1333 });
1334 assert_eq!(updates.len(), 1);
1335 assert!(matches!(
1336 updates[0],
1337 acp::schema::v1::SessionUpdate::AgentMessageChunk(_)
1338 ));
1339 }
1340
1341 #[test]
1342 fn assistant_tool_use_part_becomes_tool_call() {
1343 let updates = session_event_to_updates(zeph_session::SessionEvent::AssistantMessage {
1344 parts: vec![zeph_llm::provider::MessagePart::ToolUse {
1345 id: "call_0".to_owned(),
1346 name: "shell".to_owned(),
1347 input: serde_json::json!({"cmd": "ls"}),
1348 }],
1349 });
1350 assert_eq!(updates.len(), 1);
1351 assert!(matches!(
1352 updates[0],
1353 acp::schema::v1::SessionUpdate::ToolCall(_)
1354 ));
1355 }
1356
1357 #[test]
1358 fn assistant_message_maps_each_part_independently() {
1359 let updates = session_event_to_updates(zeph_session::SessionEvent::AssistantMessage {
1360 parts: vec![
1361 zeph_llm::provider::MessagePart::ToolUse {
1362 id: "call_0".to_owned(),
1363 name: "shell".to_owned(),
1364 input: serde_json::json!({}),
1365 },
1366 zeph_llm::provider::MessagePart::Text {
1367 text: "done".to_owned(),
1368 },
1369 ],
1370 });
1371 assert_eq!(updates.len(), 2);
1372 assert!(matches!(
1373 updates[0],
1374 acp::schema::v1::SessionUpdate::ToolCall(_)
1375 ));
1376 assert!(matches!(
1377 updates[1],
1378 acp::schema::v1::SessionUpdate::AgentMessageChunk(_)
1379 ));
1380 }
1381
1382 #[test]
1383 fn tool_result_becomes_tool_call_update_with_status() {
1384 let updates = session_event_to_updates(zeph_session::SessionEvent::ToolResult {
1385 id: "call_0".to_owned(),
1386 name: "shell".to_owned(),
1387 output: "ok".to_owned(),
1388 is_error: false,
1389 duration_ms: 10,
1390 });
1391 assert_eq!(updates.len(), 1);
1392 let acp::schema::v1::SessionUpdate::ToolCallUpdate(update) = &updates[0] else {
1393 panic!("expected ToolCallUpdate");
1394 };
1395 assert_eq!(
1396 update.fields.status,
1397 Some(acp::schema::v1::ToolCallStatus::Completed)
1398 );
1399 }
1400
1401 #[test]
1402 fn failed_tool_result_maps_to_failed_status() {
1403 let updates = session_event_to_updates(zeph_session::SessionEvent::ToolResult {
1404 id: "call_0".to_owned(),
1405 name: "shell".to_owned(),
1406 output: "boom".to_owned(),
1407 is_error: true,
1408 duration_ms: 10,
1409 });
1410 let acp::schema::v1::SessionUpdate::ToolCallUpdate(update) = &updates[0] else {
1411 panic!("expected ToolCallUpdate");
1412 };
1413 assert_eq!(
1414 update.fields.status,
1415 Some(acp::schema::v1::ToolCallStatus::Failed)
1416 );
1417 }
1418
1419 #[test]
1420 fn bookkeeping_events_produce_no_client_visible_update() {
1421 assert!(
1422 session_event_to_updates(zeph_session::SessionEvent::SessionStarted {
1423 session_id: "s1".to_owned(),
1424 cwd: "/tmp".to_owned(),
1425 provider_name: "claude".to_owned(),
1426 model: "opus".to_owned(),
1427 forked_from: None,
1428 })
1429 .is_empty()
1430 );
1431 assert!(
1432 session_event_to_updates(zeph_session::SessionEvent::ForkPoint {
1433 new_session_id: "s2".to_owned(),
1434 })
1435 .is_empty()
1436 );
1437 assert!(
1438 session_event_to_updates(zeph_session::SessionEvent::SessionEnded {
1439 reason: "user_quit".to_owned(),
1440 })
1441 .is_empty()
1442 );
1443 }
1444}