/// ACP Session Manager — session lifecycle management. /// /// Wraps an [`AcpClient`] to provide strongly-typed session operations: /// create, prompt, cancel, and receive typed events from the agent. /// /// Reference: /// - `reference-code/hermes-vscode-main/src/sessionManager.ts` (primary) /// - `reference-code/hermes-vscode-main/src/protocol.ts` (dedup logic) use crate::acp_client::AcpClient; use crate::acp_types::{ ContentBlock, SessionNewParams, SessionNewResult, SessionPromptParams, SessionPromptResult, SessionUpdate, ToolCallStatus, }; use serde_json::{json, Value}; use std::sync::{Arc, Mutex}; use tracing::{debug, info, warn}; #[cfg(test)] use tokio::time::{sleep, Duration}; // ── Events ─────────────────────────────────────────── /// Strongly-typed event emitted by the session manager when a `session/update` arrives. #[derive(Debug, Clone)] pub enum AcpSessionEvent { /// Streaming text from the agent's response message. TextDelta { text: String }, /// Streaming reasoning/thinking text. ThoughtDelta { text: String }, /// Tool call started. ToolCall { tool_call_id: String, title: String, kind: String, status: ToolCallStatus, }, /// Tool call status update (with optional result content). ToolCallUpdate { tool_call_id: String, status: ToolCallStatus, }, /// Context usage update. UsageUpdate { used: u64, size: u64 }, /// Session metadata update (e.g. auto-title). SessionInfoUpdate { title: String }, /// Plan entries update. PlanUpdate { entries: Vec }, /// Connection closed/error. Disconnected { reason: String }, } /// Handler for session events. pub type SessionEventHandler = Arc; // ── Session state ──────────────────────────────────── #[derive(Debug, Clone, PartialEq)] pub enum SessionState { Idle, Running, Cancelling, Closed, } // ── AcpSessionManager ──────────────────────────────── /// Manages ACP sessions — create, prompt, cancel, and event dispatch. /// /// Currently supports one active session at a time. The internal [`AcpClient`] /// handles the JSON-RPC wire protocol; this layer adds session semantics and /// typed event dispatching. pub struct AcpSessionManager { client: Arc, session_id: Arc>>, state: Arc>, event_handler: Arc>>, /// Accumulated text for deduplication (per-turn). accumulated: Arc>, /// Whether we're currently inside a prompt (for dedup gating). in_prompt: Arc>, } impl AcpSessionManager { /// Create a new session manager wrapping an existing [`AcpClient`]. /// /// Registers an internal notification handler that dispatches /// `session/update` notifications as typed [`AcpSessionEvent`]s. pub fn new(client: Arc) -> Self { let session_id: Arc>> = Arc::new(Mutex::new(None)); let state: Arc> = Arc::new(Mutex::new(SessionState::Idle)); let event_handler: Arc>> = Arc::new(Mutex::new(None)); let accumulated: Arc> = Arc::new(Mutex::new(String::new())); let in_prompt: Arc> = Arc::new(Mutex::new(false)); // Wire up the ACP notification handler let session_id_clone = session_id.clone(); let event_handler_clone = event_handler.clone(); let accumulated_clone = accumulated.clone(); let in_prompt_clone = in_prompt.clone(); client.on_notification(move |method, params| { if method != "session/update" { return; } let sid = session_id_clone.lock().unwrap().clone(); if let Some(ref session_id) = sid { if let Some(msg_sid) = params.get("sessionId").and_then(|v| v.as_str()) { if msg_sid != session_id { return; // not our session } } } // Parse the update let update: SessionUpdate = match serde_json::from_value( params.get("update").cloned().unwrap_or(Value::Null), ) { Ok(u) => u, Err(e) => { warn!("ACP session/update parse error: {e}"); return; } }; let is_in_prompt = *in_prompt_clone.lock().unwrap(); let event = Self::session_update_to_event(&update, &accumulated_clone, is_in_prompt); if let Some(ev) = event { let handler = event_handler_clone.lock().unwrap(); if let Some(ref h) = *handler { h(ev); } } }); Self { client, session_id, state, event_handler, accumulated, in_prompt, } } /// Register an event handler for session events. /// Only one handler at a time — subsequent calls replace the previous. pub fn on_event(&self, handler: F) where F: Fn(AcpSessionEvent) + Send + Sync + 'static, { let mut guard = self.event_handler.lock().unwrap(); *guard = Some(Arc::new(handler)); } /// Create a new ACP session. /// /// Sends `session/new` to the agent and stores the returned `sessionId`. /// The `page_context` is optional metadata about the current document. pub async fn create_session( &self, cwd: Option<&str>, _page_context: Option, ) -> Result { let project_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .ancestors() .nth(3) .unwrap_or(std::path::Path::new("/mnt/Data1T/mnote")) .to_string_lossy() .to_string(); let params = SessionNewParams { cwd: Some(cwd.map(str::to_string).unwrap_or(project_root)), mcp_servers: Some(Vec::new()), }; let result: SessionNewResult = self.client.request("session/new", params).await?; let mut sid_guard = self.session_id.lock().unwrap(); *sid_guard = Some(result.session_id.clone()); info!("ACP session created: {}", result.session_id); Ok(result.session_id) } /// Send a prompt to the agent and stream events. /// /// The `prompt` is a list of content blocks (text + optional page context). /// Returns once the agent finishes (stopReason received) or on error. /// /// Sets `in_prompt = true` during the call to enable deduplication, /// then resets to `false` and clears accumulated text on completion. pub async fn run_prompt( &self, prompt: Vec, ) -> Result { { let mut in_prompt = self.in_prompt.lock().unwrap(); *in_prompt = true; } { let mut acc = self.accumulated.lock().unwrap(); acc.clear(); } { let mut state = self.state.lock().unwrap(); *state = SessionState::Running; } let sid = self.session_id.lock().unwrap().clone(); let session_id = sid.ok_or_else(|| { self.reset_prompt_state(); crate::acp_client::AcpError::Internal( "no session created yet — call create_session first".into(), ) })?; let params = SessionPromptParams { session_id: session_id.clone(), prompt, }; debug!("ACP session/prompt (session={})", session_id); let result: Result = self.client.request("session/prompt", params).await; if let Ok(ref prompt_result) = result { debug!( "ACP session/prompt done (session={}, stop_reason={:?})", session_id, prompt_result.stop_reason ); } self.reset_prompt_state(); result } /// Cancel the current prompt. /// /// Sends `session/cancel` notification to the agent. pub async fn cancel(&self) -> Result<(), crate::acp_client::AcpError> { let sid = self.session_id.lock().unwrap().clone(); let session_id = sid.ok_or_else(|| crate::acp_client::AcpError::Internal("no active session".into()))?; { let mut state = self.state.lock().unwrap(); *state = SessionState::Cancelling; } self.client .notification("session/cancel", json!({ "sessionId": session_id })) .await?; info!("ACP session cancelled: {}", session_id); Ok(()) } /// Close the session and the underlying ACP client. pub async fn close(&self) { { let mut state = self.state.lock().unwrap(); *state = SessionState::Closed; } if let Some(handler) = self.event_handler.lock().unwrap().take() { handler(AcpSessionEvent::Disconnected { reason: "session closed".into(), }); } } /// Get the current session ID, if any. pub async fn session_id(&self) -> Option { self.session_id.lock().unwrap().clone() } /// Get the current session state. pub async fn state(&self) -> SessionState { self.state.lock().unwrap().clone() } fn reset_prompt_state(&self) { { let mut state = self.state.lock().unwrap(); *state = SessionState::Idle; } { let mut in_prompt = self.in_prompt.lock().unwrap(); *in_prompt = false; } { let mut acc = self.accumulated.lock().unwrap(); acc.clear(); } } // ── Internal: session/update → event mapping ───── /// Convert a parsed [`SessionUpdate`] into an [`AcpSessionEvent`], /// applying text deduplication for streaming text. /// /// Reference: `hermes-vscode-main/src/protocol.ts` `extractTextContent()`, /// `deduplicateChunk()`, `parseToolCall()`, `parseToolCallUpdate()`, /// `parseUsageUpdate()`, `parseSessionInfoUpdate()` fn session_update_to_event( update: &SessionUpdate, accumulated: &Arc>, is_in_prompt: bool, ) -> Option { match update { SessionUpdate::AgentMessageChunk { content, .. } | SessionUpdate::AgentThoughtChunk { content, .. } => { let discrim = match update { SessionUpdate::AgentMessageChunk { .. } => "msg", SessionUpdate::AgentThoughtChunk { .. } => "thought", _ => unreachable!(), }; // Deduplication (reference: protocol.ts deduplicateChunk) if is_in_prompt { let mut acc = accumulated.lock().unwrap(); let text = &content.text; let event = if text == acc.as_str() { // Exact full resend → drop None } else if text.len() > 10 && text.starts_with(acc.as_str()) { // Superset resend → emit only the tail let new_part = text[acc.len()..].to_string(); if new_part.is_empty() { None } else { *acc = text.clone(); Some(new_part) } } else if text.len() > 10 && acc.ends_with(text) { // Partial resend → drop None } else { // Normal delta let new_acc = format!("{}{}", acc, text); *acc = new_acc; Some(text.clone()) }; return event.map(|t| match discrim { "msg" => AcpSessionEvent::TextDelta { text: t }, "thought" => AcpSessionEvent::ThoughtDelta { text: t }, _ => unreachable!(), }); } // Not in prompt — emit directly (historical playback) let text = content.text.clone(); Some(match discrim { "msg" => AcpSessionEvent::TextDelta { text }, "thought" => AcpSessionEvent::ThoughtDelta { text }, _ => unreachable!(), }) } SessionUpdate::ToolCall { tool_call_id, title, kind, status, .. } => { let title = title.clone().unwrap_or_else(|| "tool".into()); let kind_str = match kind { Some(k) => format!("{:?}", k).to_lowercase(), None => "other".into(), }; let status = status.clone().unwrap_or(ToolCallStatus::Pending); Some(AcpSessionEvent::ToolCall { tool_call_id: tool_call_id.clone(), title, kind: kind_str, status, }) } SessionUpdate::ToolCallUpdate { tool_call_id, status, .. } => { let status = status.clone().unwrap_or(ToolCallStatus::Completed); Some(AcpSessionEvent::ToolCallUpdate { tool_call_id: tool_call_id.clone(), status, }) } SessionUpdate::UsageUpdate { used, size, .. } => Some(AcpSessionEvent::UsageUpdate { used: *used, size: *size, }), SessionUpdate::SessionInfoUpdate { title, .. } => { Some(AcpSessionEvent::SessionInfoUpdate { title: title.clone(), }) } SessionUpdate::Plan { entries, .. } => { let summaries: Vec = entries.iter().map(|e| e.content.clone()).collect(); Some(AcpSessionEvent::PlanUpdate { entries: summaries }) } SessionUpdate::Unknown { .. } => { warn!("ACP unknown session/update variant"); None } } } } // ── Tests ──────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use crate::acp_client::AcpClient; /// Creates a minimal ACP mock server for testing session operations. async fn spawn_mock_acp() -> Arc { let script = r#" import * as readline from 'node:readline'; import { stdin as input, stdout as output } from 'node:process'; const rl = readline.createInterface({ input, output, terminal: false }); rl.on('line', (line) => { const msg = JSON.parse(line); if (!msg.id) return; if (msg.method === 'session/new') { process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { sessionId: 'test_session_1' } }) + '\n'); } else if (msg.method === 'session/prompt') { const sessionId = msg.params?.sessionId || 'test'; process.stdout.write(JSON.stringify({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Hello from mock ACP' } } } }) + '\n'); process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { stopReason: 'end_turn' } }) + '\n'); } else if (msg.method === 'session/cancel') { // No response for notification } else if (msg.method === 'initialize') { process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] } }) + '\n'); } }); "#; let dir = std::env::temp_dir(); let script_path = dir.join("acp_session_test_mock.mjs"); std::fs::write(&script_path, script).expect("write mock"); let client = AcpClient::spawn("node", &[script_path.to_str().unwrap()]) .await .expect("spawn"); Arc::new(client) } #[tokio::test] async fn test_create_session() { let client = spawn_mock_acp().await; let mgr = AcpSessionManager::new(client); let sid = mgr .create_session(Some("/test"), None) .await .expect("create_session"); assert_eq!(sid, "test_session_1"); assert_eq!(mgr.session_id().await, Some("test_session_1".into())); } #[tokio::test] async fn test_run_prompt() { let client = spawn_mock_acp().await; let mgr = AcpSessionManager::new(client); mgr.create_session(Some("/test"), None) .await .expect("create_session"); let prompt = vec![ContentBlock::Text { text: "Hello agent".into(), }]; let result = mgr.run_prompt(prompt).await.expect("run_prompt"); assert_eq!(format!("{:?}", result.stop_reason), "EndTurn".to_string()); // After prompt, state should be idle again assert_eq!(mgr.state().await, SessionState::Idle); } #[tokio::test] async fn test_cancel() { let client = spawn_mock_acp().await; let mgr = AcpSessionManager::new(client); mgr.create_session(Some("/test"), None) .await .expect("create_session"); mgr.cancel().await.expect("cancel"); } #[tokio::test] async fn test_event_handler() { use std::sync::atomic::{AtomicBool, Ordering}; let client = spawn_mock_acp().await; let mgr = AcpSessionManager::new(client); let received = Arc::new(AtomicBool::new(false)); let r = received.clone(); mgr.on_event(move |ev| { if matches!(ev, AcpSessionEvent::TextDelta { .. }) { r.store(true, Ordering::SeqCst); } }); mgr.create_session(Some("/test"), None) .await .expect("create_session"); let prompt = vec![ContentBlock::Text { text: "Hello".into(), }]; mgr.run_prompt(prompt).await.expect("run_prompt"); // Give the notification handler time to process sleep(Duration::from_millis(200)).await; assert!( received.load(Ordering::SeqCst), "should have received TextDelta" ); } #[test] fn test_thought_chunk_maps_to_thought_delta() { let accumulated = Arc::new(Mutex::new(String::new())); let update = SessionUpdate::AgentThoughtChunk { session_update: SessionUpdate::AGENT_THOUGHT_CHUNK.into(), content: crate::acp_types::TextContent { content_type: "text".into(), text: "internal reasoning".into(), }, }; let event = AcpSessionManager::session_update_to_event(&update, &accumulated, true) .expect("thought chunk should emit event"); match event { AcpSessionEvent::ThoughtDelta { text } => assert_eq!(text, "internal reasoning"), other => panic!("expected ThoughtDelta, got {other:?}"), } } }