/// ACP ↔ SSE bridge for Hermes route integration. /// /// Transforms ACP session events into the SSE event format expected by the /// frontend (HermesRunEvent), and manages the background prompt lifecycle. /// /// Reference: `hermes-vscode-main/src/sessionManager.ts` handleUpdate() use crate::acp_runtime::AcpRuntimeManager; use crate::acp_session_manager::{AcpSessionEvent, AcpSessionManager}; use crate::acp_types::ContentBlock; use axum::body::Body; use axum::http::{header, StatusCode}; use axum::response::Response; use serde_json::{json, Value}; use std::collections::HashSet; use std::sync::Arc; use tokio::sync::broadcast; use tracing::{info, warn}; /// Errors from the ACP bridge. #[derive(Debug)] pub enum AcpBridgeError { NoActiveRuntime, SessionError(String), StreamError(String), } impl std::fmt::Display for AcpBridgeError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { AcpBridgeError::NoActiveRuntime => write!(f, "no active ACP runtime"), AcpBridgeError::SessionError(msg) => write!(f, "ACP session error: {msg}"), AcpBridgeError::StreamError(msg) => write!(f, "ACP stream error: {msg}"), } } } /// SSE event types sent to the frontend. /// Mirrors HermesRunEvent from bridge.ts. #[derive(Debug, Clone)] pub struct SseEvent { pub event: String, pub data: Value, } /// Bridge state for one run: holds the broadcast channel for SSE events. pub struct AcpRunBridge { session_id: String, event_tx: broadcast::Sender, } fn collect_citation_markdowns_from_value(value: &Value) -> Vec { fn add_citation_value(value: &Value, seen: &mut HashSet, out: &mut Vec) { let Some(citation) = value.get("citationMarkdown").and_then(Value::as_str) else { return; }; let citation = citation.trim(); if citation.is_empty() || !seen.insert(citation.to_string()) { return; } out.push(json!({ "schema": value.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.citation.v1")), "citationMarkdown": citation, "citationId": value.get("citationId").cloned().unwrap_or(Value::Null), "citationLabel": value.get("citationLabel").cloned().unwrap_or(Value::Null), "sourceRootRelativePath": value.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null), "sourcePath": value.get("sourcePath").cloned().unwrap_or(Value::Null), "filePath": value.get("filePath").or_else(|| value.get("lightRagFilePath")).cloned().unwrap_or(Value::Null), "headingPath": value.get("headingPath").cloned().unwrap_or_else(|| json!([])), "displayQuote": value.get("displayQuote").or_else(|| value.get("quote")).cloned().unwrap_or(Value::Null), "locatorEvidenceText": value.get("locatorEvidenceText").cloned().unwrap_or(Value::Null), "locatorPrecision": value.get("locatorPrecision").cloned().unwrap_or(Value::Null), "locatorDegraded": value.get("locatorDegraded").cloned().unwrap_or(Value::Bool(true)), "citationUrl": value.get("citationUrl").cloned().unwrap_or(Value::Null), })); } fn add_reference_citations( references: &[Value], seen: &mut HashSet, out: &mut Vec, ) -> bool { let has_precise = references.iter().any(|reference| { reference .get("citationMarkdown") .and_then(Value::as_str) .is_some_and(|value| !value.trim().is_empty()) && reference.get("locatorDegraded").and_then(Value::as_bool) != Some(true) }); let mut added = false; for reference in references { if out.len() >= 8 { break; } let Some(_citation) = reference.get("citationMarkdown").and_then(Value::as_str) else { continue; }; if has_precise && reference.get("locatorDegraded").and_then(Value::as_bool) == Some(true) { continue; } let before = out.len(); add_citation_value(reference, seen, out); added = added || out.len() > before; } added } fn visit(value: &Value, seen: &mut HashSet, out: &mut Vec) { if out.len() >= 8 { return; } match value { Value::String(text) => { let trimmed = text.trim(); if (trimmed.starts_with('{') || trimmed.starts_with('[')) && trimmed.contains("citationMarkdown") { if let Ok(parsed) = serde_json::from_str::(trimmed) { visit(&parsed, seen, out); } else if let Some(first_line) = trimmed.lines().next() { if let Ok(parsed) = serde_json::from_str::(first_line.trim()) { visit(&parsed, seen, out); } } } } Value::Array(items) => { for item in items { visit(item, seen, out); if out.len() >= 8 { break; } } } Value::Object(map) => { let has_filtered_references = map .get("references") .and_then(Value::as_array) .is_some_and(|references| add_reference_citations(references, seen, out)); if let Some(_citation) = map.get("citationMarkdown").and_then(Value::as_str) { if !has_filtered_references { add_citation_value(value, seen, out); } } for (key, item) in map { if has_filtered_references && matches!(key.as_str(), "references" | "citations" | "uiCitations") { continue; } visit(item, seen, out); if out.len() >= 8 { break; } } } _ => {} } } let mut seen = HashSet::new(); let mut out = Vec::new(); visit(value, &mut seen, &mut out); out } impl AcpRunBridge { /// Create a new ACP run: create session + start prompt in background. /// /// Returns a bridge with a broadcast receiver that the SSE endpoint can use. pub async fn start( runtime_mgr: &AcpRuntimeManager, runtime_name: &str, prompt_blocks: Vec, ) -> Result { // Get or activate the runtime let client = if runtime_mgr.is_active().await { runtime_mgr .active_client() .await .ok_or(AcpBridgeError::NoActiveRuntime)? } else { runtime_mgr .switch_to(runtime_name) .await .map_err(|e| AcpBridgeError::SessionError(e.to_string()))? }; // Create session manager let mgr = Arc::new(AcpSessionManager::new(client)); // Create event channel (256 buffered, enough for SSE streaming) let (event_tx, _) = broadcast::channel(256); let event_tx_clone = event_tx.clone(); // Set up event handler mgr.on_event(move |event| { if let Some(sse) = acp_event_to_sse(event) { let _ = event_tx_clone.send(sse); } }); // Create session let sid = mgr .create_session(None, None) .await .map_err(|e| AcpBridgeError::SessionError(e.to_string()))?; // Start prompt in background let mgr_clone = mgr.clone(); let event_tx_prompt = event_tx.clone(); tokio::spawn(async move { match mgr_clone.run_prompt(prompt_blocks).await { Ok(result) => { info!("ACP prompt completed: stop_reason={:?}", result.stop_reason); let _ = event_tx_prompt.send(SseEvent { event: "run.completed".into(), data: json!({ "stopReason": format!("{:?}", result.stop_reason), }), }); } Err(e) => { warn!("ACP prompt failed: {e}"); let _ = event_tx_prompt.send(SseEvent { event: "run.failed".into(), data: json!({ "error": e.to_string() }), }); } } }); info!("ACP run started: session={}", sid); Ok(Self { session_id: sid, event_tx, }) } /// Cancel the current run. pub async fn abort(&self) { // Cancellation is sent via the session manager. // For now, we just drop the bridge — the background task will detect this // via the broadcast channel being closed. info!("ACP run aborted: session={}", self.session_id); } /// Create an SSE response body from the event broadcast receiver. pub fn into_sse_response(self) -> Response { use tokio::sync::mpsc; let (tx, rx) = mpsc::channel::>(256); let mut broadcast_rx = self.event_tx.subscribe(); // Forward events from broadcast to mpsc tokio::spawn(async move { loop { match broadcast_rx.recv().await { Ok(event) => { let json = serde_json::to_string(&event.data).unwrap_or_else(|_| "{}".into()); let bytes = axum::body::Bytes::from(format!( "event: {}\ndata: {}\n\n", event.event, json )); if tx.send(Ok(bytes)).await.is_err() { break; // receiver dropped } } Err(broadcast::error::RecvError::Lagged(n)) => { warn!("ACP SSE lagged: {n} events dropped"); } Err(broadcast::error::RecvError::Closed) => { break; // stream ended } } } }); let stream = tokio_stream::wrappers::ReceiverStream::new(rx); Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8") .header(header::CACHE_CONTROL, "no-cache, no-transform") .header("x-accel-buffering", "no") .body(Body::from_stream(stream)) .unwrap_or_else(|_| { Response::builder() .status(StatusCode::INTERNAL_SERVER_ERROR) .body(Body::empty()) .unwrap() }) } } /// Map an AcpSessionEvent to an SSE event for the frontend. /// /// Reference: hermes-vscode-main protocol.ts extractTextContent/parseToolCall/parseToolCallUpdate /// Reference: recycle/wolai-frontend bridge.ts HermesRunEvent type (historic) pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option { match event { AcpSessionEvent::TextDelta { text } => Some(SseEvent { event: "message.delta".into(), data: json!({ "delta": text }), }), AcpSessionEvent::ThoughtDelta { text } => Some(SseEvent { event: "thought.delta".into(), data: json!({ "delta": text }), }), AcpSessionEvent::ToolCall { tool_call_id, title, kind, status, raw_input, locations, } => Some(SseEvent { event: "tool.started".into(), data: json!({ "tool": title, "toolCallId": tool_call_id, "kind": kind, "status": status, "input": raw_input, "locations": locations, }), }), AcpSessionEvent::ToolCallUpdate { tool_call_id, status, content, } => { let output = json!(content); let citation_markdowns = collect_citation_markdowns_from_value(&output); let error = status == crate::acp_types::ToolCallStatus::Failed; let event = if error { "tool.failed" } else if status == crate::acp_types::ToolCallStatus::Completed { "tool.completed" } else { "tool.started" }; Some(SseEvent { event: event.into(), data: json!({ "toolCallId": tool_call_id, "status": status, "error": error, "output": output, "citationMarkdowns": citation_markdowns, }), }) } AcpSessionEvent::UsageUpdate { used, size } => Some(SseEvent { event: "usage.updated".into(), data: json!({ "used": used, "size": size }), }), AcpSessionEvent::PermissionRequest { permission_id, tool_name, params, decision, } => { let event = match decision.as_str() { "allowed" => "permission.allowed", "requested" => "permission.requested", _ => "permission.denied", }; Some(SseEvent { event: event.into(), data: json!({ "permissionId": permission_id, "toolName": tool_name, "params": params, "decision": decision, }), }) } AcpSessionEvent::SessionInfoUpdate { title } => Some(SseEvent { event: "session.info.updated".into(), data: json!({ "title": title }), }), AcpSessionEvent::ProviderConversationBound { provider, remote_conversation_id, remote_url, acp_session_id, } => Some(SseEvent { event: "provider.conversation.bound".into(), data: json!({ "provider": provider, "remoteConversationId": remote_conversation_id, "remoteUrl": remote_url, "acpSessionId": acp_session_id, }), }), AcpSessionEvent::PlanUpdate { entries } => Some(SseEvent { event: "plan.updated".into(), data: json!({ "entries": entries }), }), AcpSessionEvent::Disconnected { reason } if reason == "session closed" => None, AcpSessionEvent::Disconnected { reason } => Some(SseEvent { event: "run.failed".into(), data: json!({ "error": reason }), }), } } /// Helper: get the runtime name from a profile. /// For now, we use "hermes" or "reasonix" directly. /// In Step 12, this will come from the profile config. pub fn runtime_name_for_profile(profile: &str) -> &str { match profile { "reasonix" => "reasonix", _ => "hermes", } } #[cfg(test)] mod tests { use super::*; #[test] fn acp_normal_session_close_does_not_emit_failure() { let event = AcpSessionEvent::Disconnected { reason: "session closed".into(), }; assert!(acp_event_to_sse(event).is_none()); } #[test] fn acp_unexpected_disconnect_emits_failure() { let event = AcpSessionEvent::Disconnected { reason: "transport lost".into(), }; let sse = acp_event_to_sse(event).expect("unexpected disconnect should be forwarded"); assert_eq!(sse.event, "run.failed"); assert_eq!(sse.data["error"], "transport lost"); } #[test] fn acp_thought_delta_does_not_emit_message_delta() { let event = AcpSessionEvent::ThoughtDelta { text: "internal reasoning".into(), }; let sse = acp_event_to_sse(event).expect("thought delta should be forwarded separately"); assert_eq!(sse.event, "thought.delta"); assert_eq!(sse.data["delta"], "internal reasoning"); } #[test] fn acp_permission_request_emits_frontend_decision_event() { let event = AcpSessionEvent::PermissionRequest { permission_id: "perm_1".into(), tool_name: "mnote.page.save".into(), params: json!({"documentId": "doc_1"}), decision: "denied".into(), }; let sse = acp_event_to_sse(event).expect("permission decision should be forwarded"); assert_eq!(sse.event, "permission.denied"); assert_eq!(sse.data["permissionId"], "perm_1"); assert_eq!(sse.data["toolName"], "mnote.page.save"); assert_eq!(sse.data["params"]["documentId"], "doc_1"); assert_eq!(sse.data["decision"], "denied"); } #[test] fn acp_permission_requested_emits_permission_requested_event() { let event = AcpSessionEvent::PermissionRequest { permission_id: "perm_2".into(), tool_name: "mnote.page.get".into(), params: json!({"documentId": "doc_2"}), decision: "requested".into(), }; let sse = acp_event_to_sse(event).expect("permission requested should be forwarded"); assert_eq!(sse.event, "permission.requested"); assert_eq!(sse.data["permissionId"], "perm_2"); assert_eq!(sse.data["toolName"], "mnote.page.get"); assert_eq!(sse.data["decision"], "requested"); } #[test] fn acp_permission_allowed_emits_permission_allowed_event() { let event = AcpSessionEvent::PermissionRequest { permission_id: "perm_3".into(), tool_name: "mnote.page.save".into(), params: json!({"documentId": "doc_1"}), decision: "allowed".into(), }; let sse = acp_event_to_sse(event).expect("permission allowed should be forwarded"); assert_eq!(sse.event, "permission.allowed"); assert_eq!(sse.data["permissionId"], "perm_3"); assert_eq!(sse.data["decision"], "allowed"); } #[test] fn acp_tool_events_keep_detail_for_collapsible_ui() { let started = acp_event_to_sse(AcpSessionEvent::ToolCall { tool_call_id: "tool_1".into(), title: "mnote.page.get".into(), kind: "read".into(), status: crate::acp_types::ToolCallStatus::InProgress, raw_input: Some(json!({"documentId": "doc_1", "includeBody": true})), locations: vec!["/mnt/Data1T/mnote/src/main.rs".into()], }) .expect("tool start"); assert_eq!(started.event, "tool.started"); assert_eq!(started.data["tool"], "mnote.page.get"); assert_eq!(started.data["status"], "in_progress"); assert_eq!(started.data["input"]["documentId"], "doc_1"); assert_eq!( started.data["locations"][0], "/mnt/Data1T/mnote/src/main.rs" ); assert_eq!(started.data["locations"].as_array().unwrap().len(), 1); let completed = acp_event_to_sse(AcpSessionEvent::ToolCallUpdate { tool_call_id: "tool_1".into(), status: crate::acp_types::ToolCallStatus::Completed, content: Some(vec![crate::acp_types::ContentBlockWrapper { wrapper_type: "content".into(), content: crate::acp_types::TextContent { content_type: "text".into(), text: "读取完成".into(), }, }]), }) .expect("tool complete"); assert_eq!(completed.event, "tool.completed"); assert_eq!(completed.data["status"], "completed"); assert_eq!(completed.data["output"][0]["content"]["text"], "读取完成"); let running = acp_event_to_sse(AcpSessionEvent::ToolCallUpdate { tool_call_id: "tool_1".into(), status: crate::acp_types::ToolCallStatus::InProgress, content: None, }) .expect("tool running"); assert_eq!(running.event, "tool.started"); assert_eq!(running.data["status"], "in_progress"); } #[test] fn acp_tool_completed_extracts_precise_ui_citations_from_prefixed_text() { let prefix = json!({ "schema": "mnote.acp.tool_result_ui_citations.v1", "references": [{ "citationMarkdown": "[来源定位降级:a.md](/documents/a)", "locatorDegraded": true }, { "citationMarkdown": "[b.md · p.2](/documents/b?page=2)", "locatorDegraded": false }], "uiCitations": [{ "citationMarkdown": "[来源定位降级:a.md](/documents/a)" }] }) .to_string(); let completed = acp_event_to_sse(AcpSessionEvent::ToolCallUpdate { tool_call_id: "tool_2".into(), status: crate::acp_types::ToolCallStatus::Completed, content: Some(vec![crate::acp_types::ContentBlockWrapper { wrapper_type: "content".into(), content: crate::acp_types::TextContent { content_type: "text".into(), text: format!("{prefix}\n工具正文"), }, }]), }) .expect("tool complete"); assert_eq!( completed.data["citationMarkdowns"][0]["citationMarkdown"].as_str(), Some("[b.md · p.2](/documents/b?page=2)") ); assert_eq!( completed.data["citationMarkdowns"] .as_array() .unwrap() .len(), 1 ); } #[test] fn acp_session_info_update_emits_session_info_updated_sse() { let sse = acp_event_to_sse(AcpSessionEvent::SessionInfoUpdate { title: "我的新会话标题".into(), }) .expect("session info update should be forwarded"); assert_eq!(sse.event, "session.info.updated"); assert_eq!(sse.data["title"], "我的新会话标题"); } #[test] fn acp_plan_update_emits_plan_updated_sse() { let entries = vec![ "步骤 1:读取文件".into(), "步骤 2:修改配置".into(), "步骤 3:验证更改".into(), ]; let sse = acp_event_to_sse(AcpSessionEvent::PlanUpdate { entries: entries.clone(), }) .expect("plan update should be forwarded"); assert_eq!(sse.event, "plan.updated"); let sse_entries: Vec = serde_json::from_value(sse.data["entries"].clone()).unwrap_or_default(); assert_eq!(sse_entries.len(), 3); assert_eq!(sse_entries[0], "步骤 1:读取文件"); assert_eq!(sse_entries[1], "步骤 2:修改配置"); assert_eq!(sse_entries[2], "步骤 3:验证更改"); } }