Files
mnote/rust/crates/mnote-web/src/acp_bridge.rs
T

622 lines
23 KiB
Rust
Raw Normal View History

2026-05-17 16:15:52 +08:00
/// 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;
2026-05-17 16:15:52 +08:00
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<SseEvent>,
}
fn collect_citation_markdowns_from_value(value: &Value) -> Vec<Value> {
fn add_citation_value(value: &Value, seen: &mut HashSet<String>, out: &mut Vec<Value>) {
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<String>,
out: &mut Vec<Value>,
) -> 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<String>, out: &mut Vec<Value>) {
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::<Value>(trimmed) {
visit(&parsed, seen, out);
} else if let Some(first_line) = trimmed.lines().next() {
if let Ok(parsed) = serde_json::from_str::<Value>(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
}
2026-05-17 16:15:52 +08:00
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<ContentBlock>,
) -> Result<Self, AcpBridgeError> {
// Get or activate the runtime
let client = if runtime_mgr.is_active().await {
2026-05-17 20:11:39 +08:00
runtime_mgr
.active_client()
.await
.ok_or(AcpBridgeError::NoActiveRuntime)?
2026-05-17 16:15:52 +08:00
} else {
2026-05-17 20:11:39 +08:00
runtime_mgr
.switch_to(runtime_name)
.await
2026-05-17 16:15:52 +08:00
.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
2026-05-17 20:11:39 +08:00
let sid = mgr
.create_session(None, None)
2026-05-17 16:15:52 +08:00
.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::<Result<axum::body::Bytes, std::convert::Infallible>>(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) => {
2026-05-17 20:11:39 +08:00
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
));
2026-05-17 16:15:52 +08:00
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)
2026-05-17 16:15:52 +08:00
pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
match event {
2026-05-17 20:11:39 +08:00
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 }),
}),
2026-05-17 16:15:52 +08:00
AcpSessionEvent::ToolCall {
tool_call_id,
title,
kind,
2026-05-17 21:21:59 +08:00
status,
raw_input,
2026-05-21 05:40:06 +08:00
locations,
2026-05-17 20:11:39 +08:00
} => Some(SseEvent {
event: "tool.started".into(),
data: json!({
"tool": title,
"toolCallId": tool_call_id,
"kind": kind,
2026-05-17 21:21:59 +08:00
"status": status,
"input": raw_input,
2026-05-21 05:40:06 +08:00
"locations": locations,
2026-05-17 20:11:39 +08:00
}),
}),
2026-05-17 16:15:52 +08:00
AcpSessionEvent::ToolCallUpdate {
tool_call_id,
status,
2026-05-17 21:21:59 +08:00
content,
2026-05-17 16:15:52 +08:00
} => {
let output = json!(content);
let citation_markdowns = collect_citation_markdowns_from_value(&output);
2026-05-17 16:15:52 +08:00
let error = status == crate::acp_types::ToolCallStatus::Failed;
2026-05-17 21:21:59 +08:00
let event = if error {
"tool.failed"
} else if status == crate::acp_types::ToolCallStatus::Completed {
"tool.completed"
} else {
"tool.started"
};
2026-05-17 16:15:52 +08:00
Some(SseEvent {
2026-05-17 21:21:59 +08:00
event: event.into(),
2026-05-17 16:15:52 +08:00
data: json!({
"toolCallId": tool_call_id,
2026-05-17 21:21:59 +08:00
"status": status,
2026-05-17 16:15:52 +08:00
"error": error,
"output": output,
"citationMarkdowns": citation_markdowns,
2026-05-17 16:15:52 +08:00
}),
})
}
2026-05-17 20:11:39 +08:00
AcpSessionEvent::UsageUpdate { used, size } => Some(SseEvent {
event: "usage.updated".into(),
data: json!({ "used": used, "size": size }),
}),
AcpSessionEvent::PermissionRequest {
permission_id,
tool_name,
params,
decision,
2026-05-21 05:40:06 +08:00
} => {
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 }),
}),
2026-06-01 09:29:12 +08:00
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,
}),
}),
2026-05-21 05:40:06 +08:00
AcpSessionEvent::PlanUpdate { entries } => Some(SseEvent {
event: "plan.updated".into(),
data: json!({ "entries": entries }),
}),
2026-05-17 20:11:39 +08:00
AcpSessionEvent::Disconnected { reason } if reason == "session closed" => None,
AcpSessionEvent::Disconnected { reason } => Some(SseEvent {
event: "run.failed".into(),
data: json!({ "error": reason }),
}),
2026-05-17 16:15:52 +08:00
}
}
/// 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",
}
}
2026-05-17 20:11:39 +08:00
#[cfg(test)]
mod tests {
use super::*;
2026-05-17 16:15:52 +08:00
2026-05-17 20:11:39 +08:00
#[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");
}
2026-05-17 21:21:59 +08:00
#[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");
}
2026-05-21 05:40:06 +08:00
#[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");
}
2026-05-17 21:21:59 +08:00
#[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})),
2026-05-21 05:40:06 +08:00
locations: vec!["/mnt/Data1T/mnote/src/main.rs".into()],
2026-05-17 21:21:59 +08:00
})
.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");
2026-05-21 05:40:06 +08:00
assert_eq!(
started.data["locations"][0],
"/mnt/Data1T/mnote/src/main.rs"
);
assert_eq!(started.data["locations"].as_array().unwrap().len(), 1);
2026-05-17 21:21:59 +08:00
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");
}
2026-05-21 05:40:06 +08:00
#[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
);
}
2026-05-21 05:40:06 +08:00
#[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<String> =
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:验证更改");
}
2026-05-17 20:11:39 +08:00
}