Skip to main content

zeph_acp/agent/
mod.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! ACP agent implementation — session management and IDE capability proxying.
5//!
6//! [`ZephAcpAgentState`] manages multiple concurrent ACP sessions. Each session creates
7//! an isolated agent loop via the [`AgentSpawner`] factory, runs it on a
8//! [`LoopbackChannel`], and shuttles messages between the loop and the IDE over the ACP
9//! connection. Use [`run_agent`] to drive the dispatch loop over a given transport.
10//!
11//! IDE capabilities (filesystem, terminal, LSP) are detected during `initialize()` and
12//! surfaced to the agent loop through [`AcpContext`].
13
14#[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
41/// Factory that creates a provider by `{provider}:{model}` key.
42///
43/// Called when the IDE sends `set_session_config_option` with a new model selection.
44/// Returns `None` when the requested key is not recognized.
45///
46/// # Examples
47///
48/// ```rust,no_run
49/// use std::sync::Arc;
50/// use zeph_acp::agent::ProviderFactory;
51///
52/// let factory: ProviderFactory = Arc::new(|key| {
53///     // key format: "openai:gpt-4o" or "ollama:llama3"
54///     let _key = key;
55///     None // return Some(provider) for known keys
56/// });
57/// ```
58pub type ProviderFactory = Arc<dyn Fn(&str) -> Option<AnyProvider> + Send + Sync>;
59
60/// Per-session context passed to the agent spawner.
61///
62/// Provides the session identity and persistence handles needed to bootstrap
63/// an agent loop for an individual ACP session.
64///
65/// `conversation_id` is `Some` when a SQLite-backed [`ConversationId`] was
66/// successfully created or retrieved for this session. `None` means the store
67/// was unavailable at session creation time; the agent operates without
68/// persistent history in that case.
69pub struct SessionContext {
70    /// ACP-assigned session identifier.
71    pub session_id: acp::schema::v1::SessionId,
72    /// `SQLite` conversation ID for persisting message history, if available.
73    pub conversation_id: Option<ConversationId>,
74    /// Working directory reported by the IDE for this session.
75    pub working_dir: PathBuf,
76}
77
78/// IDE-proxied capabilities passed to the agent loop per session.
79///
80/// Each field is `None` when the IDE did not advertise the corresponding capability
81/// during the ACP `initialize()` handshake. The agent loop should degrade gracefully
82/// when optional capabilities are absent.
83pub struct AcpContext {
84    /// IDE-proxied filesystem executor (`fs.readTextFile` / `fs.writeTextFile`).
85    ///
86    /// `None` when the IDE did not advertise filesystem capability.
87    pub file_executor: Option<AcpFileExecutor>,
88    /// IDE-proxied shell executor (`terminal.create` / `terminal.execute`).
89    ///
90    /// `None` when the IDE did not advertise terminal capability.
91    pub shell_executor: Option<AcpShellExecutor>,
92    /// Permission gate for tool-call approval requests sent to the IDE.
93    ///
94    /// `None` when the IDE did not advertise permission capability.
95    pub permission_gate: Option<AcpPermissionGate>,
96    /// Shared cancellation signal.
97    ///
98    /// Notify this to interrupt the currently running agent operation (e.g. on user cancel).
99    pub cancel_signal: std::sync::Arc<tokio::sync::Notify>,
100    /// Shared slot for runtime model switching via `set_session_config_option`.
101    ///
102    /// When `Some`, the agent should swap its provider before the next turn.
103    pub provider_override: Arc<RwLock<Option<AnyProvider>>>,
104    /// Tool call ID of the parent agent's tool call that spawned this subagent session.
105    ///
106    /// `None` for top-level (non-subagent) sessions.
107    pub parent_tool_use_id: Option<String>,
108    /// LSP provider when the IDE advertised `meta["lsp"]` capability.
109    ///
110    /// `None` when the IDE does not support LSP extension methods.
111    pub lsp_provider: Option<crate::lsp::AcpLspProvider>,
112    /// Shared diagnostics cache — written by the LSP notification handler in `ZephAcpAgent`
113    /// and read by the agent loop context builder to inject diagnostics into the system prompt.
114    pub diagnostics_cache: Arc<RwLock<DiagnosticsCache>>,
115    /// Handle for proactively notifying the client outside of the prompt-drain path.
116    ///
117    /// See [`SessionStatusNotifier`] for why this exists alongside `LoopbackChannel::send_status`.
118    pub status_notifier: SessionStatusNotifier,
119    /// Elicitation bridge for sending form requests to the IDE.
120    ///
121    /// `None` when the IDE did not advertise elicitation capability during `initialize()`,
122    /// or when the `unstable-elicitation` feature is disabled.
123    #[cfg(feature = "unstable-elicitation")]
124    #[allow(dead_code)]
125    pub(crate) elicitation_bridge: Option<elicitation::ElicitationBridge>,
126}
127
128/// Factory that receives a [`LoopbackChannel`], optional [`AcpContext`], and [`SessionContext`],
129/// then drives the agent loop to completion.
130///
131/// Each invocation creates an independent agent with its own conversation history,
132/// enabling true multi-session isolation. The future is `'static` but not `Send`
133/// (`Agent<LoopbackChannel>` holds non-`Send` references across `.await`); scheduled
134/// via `tokio::task::spawn_local` inside a `LocalSet`. The ACP transport runtime
135/// (`serve_stdio`/`serve_connection`) already wraps the dispatcher in a `LocalSet`,
136/// so handler code may call `spawn_local` directly without additional setup.
137///
138/// # Examples
139///
140/// ```rust,no_run
141/// use std::sync::Arc;
142/// use zeph_acp::{AgentSpawner, AcpContext, SessionContext};
143/// use zeph_core::channel::LoopbackChannel;
144///
145/// let spawner: AgentSpawner = Arc::new(|channel, ctx, session| {
146///     Box::pin(async move {
147///         // drive your agent loop here
148///         drop((channel, ctx, session));
149///     })
150/// });
151/// ```
152pub 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/// Thread-safe variant of [`AgentSpawner`] required by the HTTP transport.
164///
165/// Used with [`AcpHttpState`](crate::transport::http::AcpHttpState) to satisfy
166/// `axum::State` requirements (`Send + Sync`). In practice this is the same type
167/// alias — the distinction exists to make the intent clear at call sites.
168#[cfg(feature = "acp-http")]
169#[cfg_attr(docsrs, doc(cfg(feature = "acp-http")))]
170pub type SendAgentSpawner = AgentSpawner;
171
172/// Sender half for delivering session notifications to the per-session drainer.
173///
174/// `pub` (not `pub(crate)`) solely so [`SessionStatusNotifier::new`] can appear in the public
175/// API: it lets integration tests outside this crate construct a real notifier bound to a
176/// plain `mpsc::channel`, without a full `AcpContext`/ACP connection.
177pub type NotifySender = mpsc::Sender<(acp::schema::v1::SessionNotification, oneshot::Sender<()>)>;
178
179/// Receiver half paired with [`NotifySender`].
180pub(crate) type NotifyReceiver =
181    mpsc::Receiver<(acp::schema::v1::SessionNotification, oneshot::Sender<()>)>;
182
183/// Fire-and-forget handle for pushing a client-visible status update outside of the normal
184/// prompt-drain path.
185///
186/// Most agent output reaches the client through [`LoopbackChannel`] and is only flushed to
187/// the IDE as part of a `session/prompt` response (see `helpers::loopback_event_to_updates`
188/// and `drain_agent_events`). Some failures are discovered before any prompt is ever sent —
189/// e.g. session hydration in `spawn_acp_agent` (`zeph` binary crate) hitting
190/// `SessionError::AlreadyLocked` — so a client that never prompts, or whose first prompt is
191/// cancelled before the drain, would otherwise never learn persistence degraded (#5519).
192/// `SessionStatusNotifier` reuses the same per-session notification channel that
193/// `ZephAcpAgentState::send_notification_nowait` already drives for other proactive updates
194/// (e.g. `available_commands_update`), so it delivers immediately via the session's notify
195/// drainer instead of waiting on the next prompt.
196#[derive(Clone)]
197pub struct SessionStatusNotifier {
198    notify_tx: NotifySender,
199    session_id: acp::schema::v1::SessionId,
200}
201
202impl SessionStatusNotifier {
203    /// Builds a notifier bound to a session's notification channel.
204    ///
205    /// `notify_tx` is normally a `SessionEntry`'s own notify sender (see `build_acp_context`),
206    /// so pushes from this notifier are drained by the same task that delivers this session's
207    /// `session/update` notifications to the client. `pub` (not `pub(crate)`) so integration
208    /// tests outside this crate can bind a notifier to a plain `mpsc::channel` and assert on
209    /// the receiving end directly, without constructing a full `AcpContext`/ACP connection.
210    ///
211    /// # Examples
212    ///
213    /// ```
214    /// use agent_client_protocol::schema::v1::SessionId;
215    /// use tokio::sync::mpsc;
216    /// use zeph_acp::SessionStatusNotifier;
217    ///
218    /// let (tx, mut rx) = mpsc::channel(4);
219    /// let notifier = SessionStatusNotifier::new(tx, SessionId::new("session-1".to_owned()));
220    /// notifier.notify_status_nowait("degraded");
221    /// assert!(rx.try_recv().is_ok());
222    /// ```
223    #[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    /// Push a status message to the client immediately, without waiting for an ack.
232    ///
233    /// Mirrors the `AgentThoughtChunk` shape `loopback_event_to_updates` already produces for
234    /// `LoopbackEvent::Status`, so proactive and prompt-drained status messages render
235    /// identically on the client. Errors (channel full or closed) are logged and swallowed —
236    /// same tolerance as `ZephAcpAgentState::send_notification_nowait`.
237    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
257/// Per-session config fields seeded into a fresh `SessionEntry` (#5373).
258///
259/// Callers pass either configured defaults (new/loaded session) or values inherited from a
260/// source session (fork/resume of an existing session) — see `inherited_session_config`.
261pub(crate) struct SessionConfigSeed {
262    thinking_enabled: bool,
263    auto_approve_level: String,
264    temperature_preset: zeph_config::AcpTemperaturePreset,
265}
266
267/// Monotonic counter assigned to every `SessionEntry` at construction (`make_session_entry`).
268///
269/// Lets `turn::PromptChannelGuard`, captured at the start of a turn, detect at restore time
270/// whether the session map still holds the *same* entry it started with. `do_load_session` /
271/// `do_resume_session` both early-return without inserting anything if the `SessionId` is
272/// already present in the map — so a fresh `SessionEntry` only ever lands under an id that a
273/// prior `do_close_session`/`do_delete_session` has already `remove()`-d. Neither of those
274/// removals waits for or aborts any turn still in flight on that session, so a
275/// `PromptChannelGuard` acquired before the close can outlive it and still be holding the
276/// (now orphaned) receiver when the id is reloaded/resumed (#6666). The fresh entry gets a new
277/// generation, so the stale guard's later `Drop` can tell its receiver is no longer the live
278/// one and skip clobbering the reloaded session's `output_rx`.
279static SESSION_ENTRY_GENERATION: AtomicU64 = AtomicU64::new(0);
280
281pub(crate) struct SessionEntry {
282    pub(crate) input_tx: mpsc::Sender<ChannelMessage>,
283    /// Receiver is owned solely by the `prompt()` handler.
284    /// `Mutex` instead of `RefCell` so `SessionEntry` is `Send`.
285    pub(crate) output_rx: Mutex<Option<mpsc::Receiver<LoopbackEvent>>>,
286    /// Identity stamp from [`SESSION_ENTRY_GENERATION`], assigned once at construction.
287    /// See that constant's doc for why this exists.
288    pub(crate) generation: u64,
289    pub(crate) cancel_signal: Arc<tokio::sync::Notify>,
290    /// Epoch milliseconds; updated on every prompt.
291    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    /// Channel for sending notifications to the per-session drainer task.
295    pub(crate) notify_tx: NotifySender,
296    /// Receiver consumed by the drainer task spawned in `new_session` / `load_session`.
297    /// Wrapped in `Mutex` so it can be `take()`-n exactly once.
298    pub(crate) notify_rx: Mutex<Option<NotifyReceiver>>,
299    /// Shared provider override slot; written by `set_session_config_option`, read by agent loop.
300    provider_override: Arc<RwLock<Option<AnyProvider>>>,
301    /// Currently selected model identifier (display / tracking only).
302    current_model: Mutex<String>,
303    /// Current session mode (ask / architect / code).
304    current_mode: Mutex<acp::schema::v1::SessionModeId>,
305    /// Set after the first successful prompt so title generation fires only once.
306    first_prompt_done: AtomicBool,
307    /// Auto-generated session title; populated after first prompt via `SessionTitle` event.
308    title: Mutex<Option<String>>,
309    /// Whether extended thinking is enabled for this session.
310    thinking_enabled: AtomicBool,
311    /// Auto-approve level for this session ("suggest" | "auto-edit" | "full-auto").
312    auto_approve_level: Mutex<String>,
313    /// Sampling-temperature preset for this session, advertised under the `model_config`
314    /// `session/set_config_option` category (`config_id = "temperature"`).
315    temperature_preset: Mutex<zeph_config::AcpTemperaturePreset>,
316    /// Shell executor for this session, retained so the event loop can release terminals
317    /// after `tool_call_update` notifications are sent (ACP requires the terminal to
318    /// remain alive until after the notification that embeds it).
319    pub(crate) shell_executor: Option<AcpShellExecutor>,
320    /// Join handle for this session's agent-loop task (`spawn_local`, spawned in
321    /// `do_new_session`/`do_load_session`/`do_fork_session`/`do_resume_session`).
322    ///
323    /// `Mutex`-wrapped (unlike `elicitation_bridge_handle` below) because it is attached
324    /// *after* the entry is already behind `self.sessions`' lock — the loop task is spawned
325    /// once `session_ctx` is ready, which depends on async work (conversation resolution)
326    /// that happens after the entry is constructed — so it needs interior mutability to be
327    /// set through a shared reference (see `set_agent_loop_handle`). `do_close_session` and
328    /// `do_delete_session` `take()` and abort+await it (bounded) before removing the entry,
329    /// so a subsequent reload/resume can never race a still-running loop left over from a
330    /// closed/deleted session generation (#6674). `Drop` also aborts it unconditionally as a
331    /// safety net for the other entry-removal path (LRU eviction in `do_fork_session`/
332    /// `do_resume_session`), mirroring `elicitation_bridge_handle`'s existing pattern.
333    pub(crate) agent_loop_handle: Mutex<Option<JoinHandle<()>>>,
334    /// Join handle for the elicitation bridge task spawned in `do_new_session`.
335    ///
336    /// Aborted on session close / reap for clean shutdown. `None` when the IDE
337    /// did not advertise elicitation capability or the feature is not enabled.
338    #[cfg(feature = "unstable-elicitation")]
339    pub(crate) elicitation_bridge_handle: Option<JoinHandle<()>>,
340    /// Lifetime token and cost totals for the session-close usage summary.
341    #[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
388/// Per-connection ACP agent state.
389///
390/// A fresh instance is built per ACP connection by `build_agent_state` — it is **not** shared
391/// across connections. Wraps session management, configuration, and per-session tool
392/// executors. Pass an `Arc<ZephAcpAgentState>` to [`run_agent`] to drive the dispatch loop.
393pub 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    /// Directory for durable per-session JSONL event logs (spec-068, #5343). `Some` when
402    /// `[session] enabled = true`; enables `ForkEngine`-based forking in `fork_conversation`.
403    pub(crate) session_data_dir: Option<std::path::PathBuf>,
404    permission_file: Option<std::path::PathBuf>,
405    /// IDE capabilities received during `initialize()`; used by `build_acp_context`.
406    pub(crate) client_caps: RwLock<acp::schema::v1::ClientCapabilities>,
407    /// Factory for creating a new provider by `{provider}:{model}` key.
408    pub(crate) provider_factory: Option<ProviderFactory>,
409    /// Available model identifiers advertised in `new_session` `config_options`.
410    available_models: SharedAvailableModels,
411    /// Shared MCP manager for `ext_method` add/remove/list.
412    pub(crate) mcp_manager: Option<Arc<McpManager>>,
413    /// Project rule file paths advertised in `new_session` `_meta`.
414    project_rules: Vec<std::path::PathBuf>,
415    /// Maximum characters for auto-generated session titles.
416    title_max_chars: usize,
417    /// Maximum number of sessions returned by `list_sessions` (0 = unlimited).
418    max_history: usize,
419    /// LSP extension configuration (from `[acp.lsp]`).
420    pub(crate) lsp_config: zeph_core::config::AcpLspConfig,
421    /// Per-agent diagnostics cache, shared between the agent (writer) and `AcpContext` (reader).
422    pub(crate) diagnostics_cache: Arc<RwLock<DiagnosticsCache>>,
423    /// Cancellation token for the idle reaper task.
424    reaper_cancel: CancellationToken,
425    /// Supervisor for long-lived agent-level background tasks (idle reaper, etc.).
426    task_supervisor: TaskSupervisor,
427    /// Canonicalized allowlist of directories ACP clients may reference in session requests.
428    additional_directories_allow: Vec<std::path::PathBuf>,
429    /// Auth methods to advertise in the `initialize` response. MVP: always `[Agent]`.
430    auth_methods_config: Vec<zeph_core::config::AcpAuthMethod>,
431    /// Timeout configuration for ACP operations (terminal, elicitation, MCP bridge).
432    pub(crate) timeouts: zeph_config::AcpTimeoutsConfig,
433    /// Model-related configuration parameters (from `[acp.model_config]`).
434    pub(crate) model_config: zeph_config::AcpModelConfigConfig,
435    /// Injection-detection-only sanitizer for advisory scanning of inbound ACP prompts.
436    ///
437    /// Spotlight wrapping is explicitly disabled: operator-typed prompts must not be
438    /// repackaged as untrusted data. The sanitizer is used solely for logging injection
439    /// pattern matches so anomalies are visible in traces and metrics.
440    prompt_injection_detector: ContentSanitizer,
441    /// Whether the IDE advertised elicitation capability during `initialize()`.
442    #[cfg(feature = "unstable-elicitation")]
443    pub(crate) elicitation_supported: std::sync::atomic::AtomicBool,
444    /// Available provider names from `[[llm.providers]]` configuration.
445    ///
446    /// Used by `providers/list` to build the response without exposing vault keys.
447    /// Each entry pairs the provider name with its protocol type.
448    #[cfg(feature = "unstable-llm-providers")]
449    pub(crate) provider_names: Vec<(String, agent_client_protocol_schema::v1::LlmProtocol)>,
450    /// Connection-scoped disabled providers (no `session_id` in ACP schema).
451    #[cfg(feature = "unstable-llm-providers")]
452    pub(crate) global_disabled_providers: Mutex<HashSet<String>>,
453    /// Connection-scoped provider overrides (no `session_id` in ACP schema).
454    #[cfg(feature = "unstable-llm-providers")]
455    pub(crate) global_provider_overrides: Mutex<HashMap<String, ProviderSetOverride>>,
456    /// Authenticated identity of this connection (#5868), scoping persisted ACP session
457    /// list/load/resume. `"acp-local"` for stdio and unauthenticated HTTP; the matched
458    /// bearer-token client id for authenticated HTTP/WS. Set once in `build_agent_state`.
459    pub(crate) owner_key: String,
460}
461
462/// Backward-compatible alias.
463pub type ZephAcpAgent = ZephAcpAgentState;
464
465impl ZephAcpAgentState {
466    /// Returns the `cancel_signal` for `session_id`, if the session is currently in memory.
467    ///
468    /// Used to bridge the real ACP `$/cancel_request` protocol notification onto the same
469    /// internal signal `session/cancel` already notifies (see `handlers/prompt.rs`).
470    #[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)] // function with many required inputs; a *Params struct would be more verbose without simplifying the call site
482    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        // Use actual IDE capabilities from initialize(); default to false (deny by default).
495        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        // EXEMPT(#5144): per-session handler tied to connection lifetime; many concurrent
509        // sessions → static name collision under TaskSupervisor::spawn. Self-terminating
510        // when the connection or cancel_signal closes.
511        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        // EXEMPT(#5144): per-session handler, same reasoning as perm_handler above.
523        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        // EXEMPT(#5144): per-session handler, same reasoning as perm_handler above.
532        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            // EXEMPT(#5144): per-session handler, same reasoning as perm_handler above.
543            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    /// Fire-and-forget notification via the session's notify channel (no ack).
598    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
617/// Handler implementations — called from `run_agent` handler closures.
618impl 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        // stdio transport implies a trusted local client; do not expose internal
640        // configuration details. Provide only a generic authentication hint.
641        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        // Advertise MCP transport capabilities when McpManager is present.
670        // Only StreamableHTTP (http=true) is supported; SSE is deprecated in MCP spec 2025-11-25.
671        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
797/// Map one durable [`zeph_session::SessionEvent`] to the ACP `SessionUpdate`(s) it replays as
798/// (spec-068 §12.3, D-2's ACP read-handler cutover).
799///
800/// `SessionStarted`/`ForkPoint`/`Condensation`/`Compaction`/`ModelChanged`/`SessionEnded` are
801/// session-log bookkeeping, not turn content — they produce no client-visible notification.
802/// `ToolCall`/`ToolResult` are handled for schema completeness even though no production write
803/// path currently emits them (today, tool use/results are embedded as `MessagePart::ToolUse`/text
804/// inside `AssistantMessage`/`UserMessage` via `persist_message`).
805///
806/// Pure and side-effect-free so the event-to-notification mapping is unit-testable without the
807/// full `serve_connection` ACP harness — see `tests::session_event_to_updates_*` below.
808fn 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
866/// Returns `true` when `trimmed_text` is an ACP-native slash command that should
867/// be handled by [`ZephAcpAgentState::handle_slash_command`] rather than forwarded
868/// to the agent loop.
869///
870/// `/review` is deliberately absent: `do_prompt` (`turn.rs`) intercepts it before this check
871/// even runs, expanding it into a real prompt that flows through the normal turn machinery
872/// instead of `handle_slash_command`'s synchronous short-circuit reply (#6673).
873fn 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
883/// Populate model caches for a single provider, then expand every other unique provider slug
884/// present in `available_models` from its on-disk cache only (no extra network calls).
885///
886/// Used both at ACP startup (`src/acp.rs`, to warm every configured provider's cache before the
887/// server starts accepting connections — one call per provider there) and by `/model refresh`
888/// (`ZephAcpAgentState::model_refresh_as_string`, #5986) for the session's single currently
889/// active provider — mirroring `Agent::model_refresh_as_string`
890/// (`crates/zeph-core/src/agent/model_commands.rs`), which likewise refreshes only the active
891/// provider rather than looping over every configured one.
892///
893/// Uses a 5-second timeout so that a slow or unavailable provider does not block the caller.
894/// Returns the number of models fetched from the live network call (`0` on error or timeout);
895/// the on-disk cache expansion for other slugs always runs regardless of that outcome.
896pub 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    // Collect unique provider slugs from the current available_models list.
935    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
971/// Map `(cancelled, stop_hint)` to the ACP `StopReason` wire value.
972fn 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
987/// Construct the `PromptResponse`, attaching per-turn token usage when the
988/// `unstable-session-usage` feature is enabled.
989fn 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                // thought_tokens: not tracked for MVP — provider may fold them into output_tokens
1002                .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
1041/// Build a request handler closure that clones `state` for each incoming request.
1042///
1043/// The closure signature matches what `Builder::on_receive_request` expects:
1044/// `(req, responder, cx) -> impl Future<Output = acp::Result<()>>`.
1045macro_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
1055/// Build a notification handler closure that clones `state` for each incoming notification.
1056macro_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/// Run the ACP agent loop over the provided transport until the connection closes.
1067///
1068/// Builds the ACP 0.11 handler chain from `state` and connects it to `transport`.
1069/// All request handlers delegate to the corresponding `do_*` methods on
1070/// [`ZephAcpAgentState`] which carry all session management logic.
1071///
1072/// # Errors
1073///
1074/// Returns an `acp::Error` if the underlying JSON-RPC transport fails.
1075///
1076/// # Examples
1077///
1078/// ```no_run
1079/// use std::sync::Arc;
1080/// use agent_client_protocol as acp;
1081/// use agent_client_protocol::ByteStreams;
1082/// use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
1083/// use zeph_acp::agent::{ZephAcpAgentState, run_agent};
1084/// use zeph_acp::AgentSpawner;
1085///
1086/// # async fn example(spawner: AgentSpawner) -> acp::Result<()> {
1087/// let state = Arc::new(ZephAcpAgentState::new(spawner, 4, 1800, None));
1088/// run_agent(
1089///     state,
1090///     ByteStreams::new(
1091///         tokio::io::stdout().compat_write(),
1092///         tokio::io::stdin().compat(),
1093///     ),
1094/// ).await
1095/// # }
1096/// ```
1097#[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
1194/// Compile-time assertions that ACP state and executors are `Send + Sync`.
1195const _: () = {
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/// Regression tests for #4528: `send_notification` must not block indefinitely.
1208#[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        // Override to a very small value so the test finishes in ~50 ms.
1222        agent.timeouts.notify_ack_timeout_ms = 50;
1223        agent
1224    }
1225
1226    /// `send_notification` must return an error within `notify_ack_timeout_ms` when
1227    /// no drainer is running (simulates a hung IDE client).
1228    #[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        // Insert without starting the drainer — no ack will ever be sent.
1251        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/// Regression tests for #5519: `SessionStatusNotifier` pushes status updates immediately,
1266/// without waiting for a prompt-drain.
1267#[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    /// Matches `loopback_event_to_updates`'s handling of `LoopbackEvent::Status("")`: empty
1293    /// text is a no-op, not an empty chunk.
1294    #[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/// Regression coverage for S1 (spec-068 §12.3 / D-2): `session_event_to_updates` is the mapping
1307/// `do_load_session` now uses to replay the durable JSONL event log instead of the emptied
1308/// `acp_session_events` table. Exercised directly (no ACP client/server harness needed) since the
1309/// function is pure.
1310#[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}