chore: move 3-14 WS push design to done/

This commit is contained in:
lix-2026
2026-05-17 16:15:52 +08:00
parent 3f43020603
commit 2ea559beaa
22 changed files with 5283 additions and 24 deletions
+249
View File
@@ -0,0 +1,249 @@
/// 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::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>,
}
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 {
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::<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) => {
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: wolai-frontend bridge.ts HermesRunEvent type
pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
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,
..
} => {
Some(SseEvent {
event: "tool.started".into(),
data: json!({
"tool": title,
"toolCallId": tool_call_id,
"kind": kind,
}),
})
}
AcpSessionEvent::ToolCallUpdate {
tool_call_id,
status,
} => {
let error = status == crate::acp_types::ToolCallStatus::Failed;
Some(SseEvent {
event: "tool.completed".into(),
data: json!({
"toolCallId": tool_call_id,
"error": error,
}),
})
}
AcpSessionEvent::UsageUpdate { used, size } => {
Some(SseEvent {
event: "usage.updated".into(),
data: json!({ "used": used, "size": size }),
})
}
AcpSessionEvent::SessionInfoUpdate { .. } => {
None // Not forwarded to frontend
}
AcpSessionEvent::PlanUpdate { .. } => {
None // Not forwarded (Phase C)
}
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",
}
}
+487
View File
@@ -0,0 +1,487 @@
/// ACP (Agent Client Protocol) JSON-RPC 2.0 client.
///
/// Walks an agent runtime subprocess (e.g. `hermes acp` or `node reasonix-acp-wrapper.mjs`)
/// over NDJSON stdio: one JSON object per line, newline-delimited.
///
/// Reference implementations:
/// - `reference-code/hermes-vscode-main/src/acpClient.ts` (primary reference)
/// - `reference-code/DeepSeek-Reasonix-main/src/acp/protocol.ts`
///
/// Wire format:
/// Request: { jsonrpc: "2.0", id: number, method: string, params?: object }
/// Response: { jsonrpc: "2.0", id: number, result?: any, error?: { code, message } }
/// Notification:{ jsonrpc: "2.0", method: string, params?: object } (no id)
/// Incoming: { jsonrpc: "2.0", id: number, method: string, params?: object } (from agent)
use serde::de::DeserializeOwned;
use serde::Serialize;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter};
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
use tokio::sync::{Mutex, oneshot};
use tokio::time::{timeout, Duration};
use tracing::{debug, info, warn};
// ── Error types ──────────────────────────────────────
#[derive(Debug)]
pub enum AcpError {
Spawn(std::io::Error),
JsonParse(serde_json::Error),
JsonRpc { code: i64, message: String },
Timeout(u64),
Closed,
Internal(String),
}
impl std::fmt::Display for AcpError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AcpError::Spawn(e) => write!(f, "ACP spawn failed: {e}"),
AcpError::JsonParse(e) => write!(f, "ACP JSON parse error: {e}"),
AcpError::JsonRpc { code, message } => {
write!(f, "ACP JSON-RPC error [{code}]: {message}")
}
AcpError::Timeout(secs) => write!(f, "ACP request timed out after {secs}s"),
AcpError::Closed => write!(f, "ACP connection closed"),
AcpError::Internal(msg) => write!(f, "ACP internal: {msg}"),
}
}
}
impl std::error::Error for AcpError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
AcpError::Spawn(e) => Some(e),
AcpError::JsonParse(e) => Some(e),
_ => None,
}
}
}
impl From<std::io::Error> for AcpError {
fn from(e: std::io::Error) -> Self {
AcpError::Spawn(e)
}
}
impl From<serde_json::Error> for AcpError {
fn from(e: serde_json::Error) -> Self {
AcpError::JsonParse(e)
}
}
// ── Notification handler type ────────────────────────
type NotificationHandler = Box<dyn Fn(String, Value) + Send + 'static>;
/// Thread-safe mutex for notification handler (std mutex — lightweight, never held across awaits).
type NotificationHandlerMutex = std::sync::Mutex<Option<NotificationHandler>>;
// ── Pending request entry ────────────────────────────
type PendingEntry = oneshot::Sender<Result<Value, AcpError>>;
// ── AcpClient ────────────────────────────────────────
/// ACP JSON-RPC 2.0 client over stdio.
///
/// Create via [`AcpClient::spawn`], then use [`request`](Self::request) for RPC
/// calls and [`notification`](Self::notification) for fire-and-forget messages.
/// Register a handler with [`on_notification`](Self::on_notification) to receive
/// agent push events (e.g. `session/update`).
// Manual Debug impl: Child doesn't impl Debug, so we skip it
impl std::fmt::Debug for AcpClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AcpClient")
.field("next_id", &self.next_id)
.field("pending_count", &self.pending.blocking_lock().len())
.finish_non_exhaustive()
}
}
pub struct AcpClient {
child: Option<Child>,
writer: Arc<Mutex<BufWriter<ChildStdin>>>,
pending: Arc<Mutex<HashMap<u64, PendingEntry>>>,
next_id: AtomicU64,
notification_handler: Arc<NotificationHandlerMutex>,
}
impl AcpClient {
/// Spawn an ACP subprocess and establish the JSON-RPC connection.
///
/// After spawn, sends an `initialize` handshake (as Hermes does in acpClient.ts
/// `start()` → `call('initialize', { protocolVersion: 1 })`).
/// Launches a background tokio task that reads NDJSON lines from the child's stdout.
///
/// Reference: `hermes-vscode-main/src/acpClient.ts` L50-80 (spawn + stdio setup)
pub async fn spawn(bin: &str, args: &[&str]) -> Result<Self, AcpError> {
let mut child = Command::new(bin)
.args(args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit())
.kill_on_drop(true)
.spawn()
.map_err(AcpError::Spawn)?;
let stdin = child.stdin.take().ok_or_else(|| {
AcpError::Internal("failed to take child stdin".into())
})?;
let stdout = child.stdout.take().ok_or_else(|| {
AcpError::Internal("failed to take child stdout".into())
})?;
let writer = BufWriter::new(stdin);
let reader = BufReader::new(stdout);
let pending: Arc<Mutex<HashMap<u64, PendingEntry>>> =
Arc::new(Mutex::new(HashMap::new()));
let notification_handler: Arc<NotificationHandlerMutex> =
Arc::new(std::sync::Mutex::new(None));
// Start background reader task
let pending_clone = pending.clone();
let handler_clone = notification_handler.clone();
let child_pid = child.id().unwrap_or(0);
tokio::spawn(async move {
Self::reader_loop(reader, pending_clone, handler_clone).await;
info!("ACP reader loop ended (pid={})", child_pid);
});
let client = Self {
child: Some(child),
writer: Arc::new(Mutex::new(writer)),
pending,
next_id: AtomicU64::new(1),
notification_handler,
};
// Handshake: initialize (reference: acpClient.ts L108 → call('initialize', {protocolVersion: 1}))
let init_result: Value = client
.request("initialize", json!({ "protocolVersion": 1 }))
.await?;
debug!(?init_result, "ACP initialize OK");
Ok(client)
}
/// Send a JSON-RPC request and await the response.
///
/// Returns `Result<R>` where `R` is the deserialized `result` field.
/// On JSON-RPC error, returns [`AcpError::JsonRpc`].
/// Default timeout: 300 seconds.
///
/// Reference: `acpClient.ts` L95-110 (`call()` method)
pub async fn request<P: Serialize, R: DeserializeOwned>(
&self,
method: &str,
params: P,
) -> Result<R, AcpError> {
self.request_with_timeout(method, params, Duration::from_secs(300)).await
}
/// Same as [`request`] but with a configurable timeout.
pub async fn request_with_timeout<P: Serialize, R: DeserializeOwned>(
&self,
method: &str,
params: P,
dur: Duration,
) -> Result<R, AcpError> {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = oneshot::channel();
self.pending.lock().await.insert(id, tx);
let req = json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
});
let line = serde_json::to_string(&req)?;
debug!("ACP --> {} #{} ({} bytes)", method, id, line.len());
let mut writer = self.writer.lock().await;
writer.write_all(line.as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await?;
match timeout(dur, rx).await {
Ok(Ok(Ok(value))) => {
let result: R = serde_json::from_value(value)?;
Ok(result)
}
Ok(Ok(Err(err))) => Err(err),
Ok(Err(_recv_err)) => Err(AcpError::Closed),
Err(_elapsed) => Err(AcpError::Timeout(dur.as_secs())),
}
}
/// Send a fire-and-forget notification (no id, no response expected).
///
/// Reference: `acpClient.ts` L115-118 (`notify()`)
pub async fn notification(&self, method: &str, params: Value) -> Result<(), AcpError> {
let msg = json!({
"jsonrpc": "2.0",
"method": method,
"params": params,
});
let line = serde_json::to_string(&msg)?;
debug!("ACP ~~> {} ({} bytes)", method, line.len());
let mut writer = self.writer.lock().await;
writer.write_all(line.as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await?;
Ok(())
}
/// Register a handler for incoming notifications (messages with `method` but no `id`).
/// Only one handler at a time — subsequent calls replace the previous.
pub fn on_notification<F>(&self, handler: F)
where
F: Fn(String, Value) + Send + 'static,
{
let mut guard = self.notification_handler.lock().unwrap();
*guard = Some(Box::new(handler));
}
/// Gracefully close the ACP connection and kill the subprocess.
pub async fn close(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.start_kill();
let _ = child.wait().await;
}
// Resolve all pending with Closed error
let mut pending = self.pending.lock().await;
for (_, tx) in pending.drain() {
let _ = tx.send(Err(AcpError::Closed));
}
}
// ── Background reader ────────────────────────────
/// Background loop: reads NDJSON lines from the child's stdout,
/// routes responses to pending requests and notifications to the handler.
///
/// Reference: `acpClient.ts` L120-180 (onData + dispatch)
async fn reader_loop(
mut reader: BufReader<ChildStdout>,
pending: Arc<Mutex<HashMap<u64, PendingEntry>>>,
notification_handler: Arc<NotificationHandlerMutex>,
) {
let mut line_buf = String::new();
loop {
line_buf.clear();
match reader.read_line(&mut line_buf).await {
Ok(0) => {
info!("ACP stdout closed (EOF)");
break;
}
Ok(_n) => {}
Err(e) => {
warn!("ACP read error: {e}");
break;
}
}
let trimmed = line_buf.trim();
if trimmed.is_empty() {
continue;
}
let msg: Value = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(e) => {
warn!("ACP parse error: {e} (line: {})", &trimmed[..trimmed.len().min(80)]);
continue;
}
};
Self::dispatch_message(msg, &pending, &notification_handler).await;
}
// Process died or EOF — resolve all pending
let mut pending_guard = pending.lock().await;
for (_, tx) in pending_guard.drain() {
let _ = tx.send(Err(AcpError::Closed));
}
}
/// Route a single JSON message to pending request, notification handler, or incoming request.
///
/// Reference: `acpClient.ts` L160-200 (dispatch)
async fn dispatch_message(
msg: Value,
pending: &Arc<Mutex<HashMap<u64, PendingEntry>>>,
notification_handler: &Arc<NotificationHandlerMutex>,
) {
let has_id = msg.get("id").is_some();
let has_method = msg.get("method").and_then(|v| v.as_str()).map(|s| !s.is_empty()).unwrap_or(false);
if has_id && has_method {
// Incoming request from agent (e.g. session/request_permission)
let method = msg["method"].as_str().unwrap_or("unknown").to_string();
let params = msg.get("params").cloned().unwrap_or(Value::Null);
// For now, reject all incoming requests since we don't need permission dialogs yet.
// Reference: acpClient.ts handleIncomingRequest (L200-220)
warn!("ACP incoming request not handled: {method} (params={params:?})");
// If we wanted to reply, we'd need to write back a response...
// For now just log. Phase C will add permission support.
} else if has_id {
// Response to one of our requests
if let Some(id) = msg["id"].as_u64() {
let mut pending_guard = pending.lock().await;
if let Some(tx) = pending_guard.remove(&id) {
if let Some(error) = msg.get("error") {
let code = error["code"].as_i64().unwrap_or(-1);
let message = error["message"]
.as_str()
.unwrap_or("unknown error")
.to_string();
let _ = tx.send(Err(AcpError::JsonRpc { code, message }));
} else if let Some(result) = msg.get("result") {
let _ = tx.send(Ok(result.clone()));
} else {
let _ = tx.send(Err(AcpError::Internal(
"response without result or error".into(),
)));
}
} else {
debug!("ACP response for unknown request id={id}");
}
}
} else if has_method {
// Notification (no id)
let method = msg["method"].as_str().unwrap_or("unknown").to_string();
let params = msg.get("params").cloned().unwrap_or(Value::Null);
let handler_guard = notification_handler.lock().unwrap();
if let Some(ref handler) = *handler_guard {
handler(method, params);
} else {
debug!("ACP notification unhandled: {method}");
}
}
}
}
impl Drop for AcpClient {
fn drop(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.start_kill();
}
}
}
// ── Tests ────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// Helper: create a mock subprocess that echoes back requests as responses.
/// Simulates a minimal ACP server for testing.
async fn spawn_mock_acp_server() -> AcpClient {
// We spawn a small node script that reads NDJSON and echoes back
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 !== undefined && msg.method) {
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');
} else {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0',
id: msg.id,
result: { ok: true, echo: msg.params }
}) + '\n');
}
} else if (msg.method && msg.id === undefined) {
// Notification → ignore
}
});
"#;
// Write script to temp file
let dir = std::env::temp_dir();
let script_path = dir.join("acp_test_mock.mjs");
std::fs::write(&script_path, script).expect("write mock script");
AcpClient::spawn("node", &[script_path.to_str().unwrap()])
.await
.expect("spawn mock ACP")
}
#[tokio::test]
async fn test_request_response() {
let client = spawn_mock_acp_server().await;
let result: Value = client
.request("test_method", json!({ "hello": "world" }))
.await
.expect("request should succeed");
assert_eq!(result["ok"], true);
assert_eq!(result["echo"]["hello"], "world");
}
#[tokio::test]
async fn test_notification() {
let client = spawn_mock_acp_server().await;
// Notifications are fire-and-forget, no response expected
client
.notification("test_notify", json!({ "foo": "bar" }))
.await
.expect("notification should succeed");
}
#[tokio::test]
async fn test_on_notification_received() {
use std::sync::atomic::AtomicBool;
let client = spawn_mock_acp_server().await;
let received = Arc::new(AtomicBool::new(false));
let received_clone = received.clone();
client.on_notification(move |method, _params| {
if method == "test_push" {
received_clone.store(true, Ordering::SeqCst);
}
});
// Send a notification that the mock server will echo back as...
// Actually the mock doesn't send unsolicited notifications.
// This test just validates the handler registration doesn't crash.
client
.notification("test_push", json!({}))
.await
.expect("notification");
// Give background task time to process
tokio::time::sleep(Duration::from_millis(100)).await;
// In this mock, no notification will be received; that's OK
}
#[tokio::test]
async fn test_close() {
let mut client = spawn_mock_acp_server().await;
client.close().await;
// Second close should be no-op
client.close().await;
}
#[tokio::test]
async fn test_initialize_handshake() {
// spawn already calls initialize; if it fails, the test fails
let _client = spawn_mock_acp_server().await;
}
}
+352
View File
@@ -0,0 +1,352 @@
/// ACP Runtime Manager — manages agent runtime subprocess lifecycle.
///
/// Supports multiple runtimes (Hermes, Reasonix) and switching between them.
/// Each runtime is spawned as a subprocess communicating via the ACP JSON-RPC 2.0 protocol.
///
/// Configuration:
/// `MNOTE_WEB_ACP_DEFAULT_RUNTIME` — default runtime name ("hermes" | "reasonix")
/// `MNOTE_WEB_HERMES_BIN` — path to Hermes binary (default: "hermes")
/// `MNOTE_WEB_REASONIX_WRAPPER` — path to Reasonix wrapper script (default: "scripts/reasonix-acp-wrapper.mjs")
///
/// Or via JSON env var:
/// `MNOTE_WEB_ACP_RUNTIMES` — JSON array of runtime configs
use crate::acp_client::{AcpClient, AcpError};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::env;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::time::{timeout, Duration};
use tracing::{debug, info, warn};
// ── Config ───────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AcpRuntimeConfig {
/// Display name (e.g. "hermes", "reasonix").
pub name: String,
/// Binary path (e.g. "hermes", "node").
pub bin: String,
/// Command arguments (e.g. ["acp"], ["scripts/reasonix-acp-wrapper.mjs"]).
#[serde(default)]
pub args: Vec<String>,
/// Extra environment variables.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env: Option<HashMap<String, String>>,
/// Human-readable title for the runtime selector.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
}
impl AcpRuntimeConfig {
/// Create a Hermes ACP runtime config.
pub fn hermes(bin: Option<&str>) -> Self {
Self {
name: "hermes".into(),
bin: bin.unwrap_or("hermes").to_string(),
args: vec!["acp".into()],
env: None,
title: Some("Hermes".into()),
}
}
/// Create a Reasonix ACP runtime config.
/// `wrapper_path` is relative to the project root (where Cargo.toml's parent is).
/// Default: `design/05-editor-mainline/reference-code/DeepSeek-Reasonix-main/scripts/reasonix-acp-wrapper.mjs` (dev),
/// or in production, the absolute path is resolved via `CARGO_MANIFEST_DIR` (the `rust/` directory).
pub fn reasonix(wrapper_path: Option<&str>) -> Self {
// CARGO_MANIFEST_DIR is the directory containing this crate's Cargo.toml:
// /mnt/Data1T/mnote/rust/crates/mnote-web/
// We need the project root: /mnt/Data1T/mnote/
let project_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(3) // up: mnote-web/ → crates/ → rust/ → mnote/ (project root)
.unwrap_or(std::path::Path::new("/mnt/Data1T/mnote"))
.to_string_lossy()
.to_string();
let default_path = format!("{project_root}/scripts/reasonix-acp-wrapper.mjs");
// Resolve wrapper_path: if it's relative, prepend project_root; absolute paths used as-is
let resolved_path = wrapper_path.map(|p| {
if p.starts_with('/') {
p.to_string()
} else {
format!("{project_root}/{p}")
}
}).unwrap_or(default_path);
Self {
name: "reasonix".into(),
bin: "node".into(),
args: vec![resolved_path],
env: None,
title: Some("Reasonix".into()),
}
}
}
// ── Runtime Manager ──────────────────────────────────
/// Manages lifecycle of multiple agent runtimes.
///
/// Each runtime is defined by a name and spawn configuration.
/// At most one runtime is "active" at a time, providing an [`AcpClient`].
#[derive(Debug)]
pub struct AcpRuntimeManager {
runtimes: HashMap<String, AcpRuntimeConfig>,
active: Mutex<Option<ActiveRuntime>>,
default_runtime: String,
}
#[derive(Debug)]
struct ActiveRuntime {
config: AcpRuntimeConfig,
client: Arc<AcpClient>,
}
impl AcpRuntimeManager {
/// Create a new runtime manager with built-in default configurations.
///
/// Reads environment variables to configure Hermes and Reasonix runtimes.
/// Default active runtime is set by `MNOTE_WEB_ACP_DEFAULT_RUNTIME` (default: "hermes").
pub fn from_env() -> Self {
let mut runtimes: HashMap<String, AcpRuntimeConfig> = HashMap::new();
// Check for JSON-based configuration first
if let Ok(json) = env::var("MNOTE_WEB_ACP_RUNTIMES") {
if let Ok(custom_runtimes) =
serde_json::from_str::<Vec<AcpRuntimeConfig>>(&json)
{
for rt in custom_runtimes {
let name = rt.name.clone();
runtimes.insert(name, rt);
}
} else {
warn!("Failed to parse MNOTE_WEB_ACP_RUNTIMES JSON");
}
}
// Always add default Hermes if not already configured
if !runtimes.contains_key("hermes") {
let hermes_bin = env::var("MNOTE_WEB_HERMES_BIN")
.unwrap_or_else(|_| "hermes".into());
runtimes.insert("hermes".into(), AcpRuntimeConfig::hermes(Some(&hermes_bin)));
}
// Always add default Reasonix if not already configured
if !runtimes.contains_key("reasonix") {
let wrapper = env::var("MNOTE_WEB_REASONIX_WRAPPER")
.unwrap_or_else(|_| "scripts/reasonix-acp-wrapper.mjs".into());
runtimes.insert("reasonix".into(), AcpRuntimeConfig::reasonix(Some(&wrapper)));
}
let default = env::var("MNOTE_WEB_ACP_DEFAULT_RUNTIME")
.unwrap_or_else(|_| "hermes".into());
Self {
runtimes,
active: Mutex::new(None),
default_runtime: default,
}
}
/// Get the list of available runtime names.
pub fn available_runtimes(&self) -> Vec<String> {
self.runtimes.keys().cloned().collect()
}
/// Get a runtime config by name.
pub fn get_config(&self, name: &str) -> Option<&AcpRuntimeConfig> {
self.runtimes.get(name)
}
/// Get the default runtime name.
pub fn default_runtime(&self) -> &str {
&self.default_runtime
}
/// Get the currently active runtime name, if any.
pub async fn active_runtime_name(&self) -> Option<String> {
self.active.lock().await.as_ref().map(|a| a.config.name.clone())
}
/// Get a reference to the currently active [`AcpClient`], if any.
pub async fn active_client(&self) -> Option<Arc<AcpClient>> {
self.active.lock().await.as_ref().map(|a| a.client.clone())
}
/// Check if a runtime is active and the client is available.
pub async fn is_active(&self) -> bool {
self.active.lock().await.is_some()
}
/// Activate a runtime by name, spawning a new subprocess if needed.
///
/// If another runtime is currently active, it will be shut down first.
/// After spawn, performs an `initialize` handshake to verify the runtime is healthy.
pub async fn switch_to(&self, name: &str) -> Result<Arc<AcpClient>, AcpError> {
let config = self
.runtimes
.get(name)
.cloned()
.ok_or_else(|| AcpError::Internal(format!("unknown runtime: {name}")))?;
// Shutdown current active runtime
let mut active_guard = self.active.lock().await;
if let Some(ref current) = *active_guard {
if current.config.name == name {
// Already active — return existing client
return Ok(current.client.clone());
}
// Drop the old ActiveRuntime, which will kill the child process
// (via AcpClient's Drop impl)
}
info!("ACP runtime: switching to {name} (bin={}, args={:?})", config.bin, config.args);
// Convert Vec<String> to Vec<&str> for AcpClient::spawn
let args_refs: Vec<&str> = config.args.iter().map(|s| s.as_str()).collect();
let client = AcpClient::spawn(&config.bin, &args_refs).await?;
let client = Arc::new(client);
*active_guard = Some(ActiveRuntime {
config,
client: client.clone(),
});
info!("ACP runtime: {name} active");
Ok(client)
}
/// Shut down the currently active runtime.
pub async fn shutdown_active(&self) {
let mut active_guard = self.active.lock().await;
if let Some(active) = active_guard.take() {
info!("ACP runtime: shutting down {}", active.config.name);
// AcpClient's Drop kills the process
}
}
/// Perform a health check on the active runtime.
///
/// Returns `true` if the runtime responds to an `initialize` handshake within 5 seconds.
pub async fn health_check(&self) -> bool {
let client = match self.active_client().await {
Some(c) => c,
None => return false,
};
// Use request_with_timeout with a short timeout
let result: Result<serde_json::Value, AcpError> = timeout(
Duration::from_secs(5),
client.request("initialize", serde_json::json!({ "protocolVersion": 1 })),
)
.await
.map_err(|_| AcpError::Timeout(5))
.and_then(|r| r);
match result {
Ok(val) => {
let ok = val.get("protocolVersion").and_then(|v| v.as_u64()) == Some(1);
if ok {
debug!("ACP health check OK");
} else {
warn!("ACP health check: unexpected response: {val:?}");
}
ok
}
Err(e) => {
warn!("ACP health check failed: {e}");
false
}
}
}
}
impl Drop for AcpRuntimeManager {
fn drop(&mut self) {
// The active runtime's AcpClient Drop will kill the process
}
}
// ── Tests ────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_runtime_config_hermes() {
let cfg = AcpRuntimeConfig::hermes(None);
assert_eq!(cfg.name, "hermes");
assert_eq!(cfg.bin, "hermes");
assert_eq!(cfg.args, vec!["acp"]);
}
#[test]
fn test_runtime_config_reasonix() {
let cfg = AcpRuntimeConfig::reasonix(None);
assert_eq!(cfg.name, "reasonix");
assert_eq!(cfg.bin, "node");
assert_eq!(cfg.args, vec!["scripts/reasonix-acp-wrapper.mjs"]);
}
#[test]
fn test_runtime_config_custom() {
let cfg = AcpRuntimeConfig {
name: "custom".into(),
bin: "/usr/local/bin/my-agent".into(),
args: vec!["--acp".into(), "--debug".into()],
env: None,
title: Some("My Agent".into()),
};
let json = serde_json::to_value(&cfg).unwrap();
assert_eq!(json["name"], "custom");
assert_eq!(json["bin"], "/usr/local/bin/my-agent");
assert_eq!(json["title"], "My Agent");
}
#[test]
fn test_runtime_manager_from_env_defaults() {
// Without env overrides, should contain hermes and reasonix
let mgr = AcpRuntimeManager::from_env();
let runtimes = mgr.available_runtimes();
assert!(runtimes.contains(&"hermes".into()));
assert!(runtimes.contains(&"reasonix".into()));
}
#[tokio::test]
async fn test_switch_to_unknown_runtime() {
let mgr = AcpRuntimeManager::from_env();
let result = mgr.switch_to("nonexistent").await;
assert!(result.is_err());
let err_str = format!("{}", result.err().unwrap());
assert!(
err_str.contains("unknown runtime"),
"should return error for unknown runtime, got: {err_str}"
);
}
#[tokio::test]
async fn test_health_check_no_active() {
let mgr = AcpRuntimeManager::from_env();
assert!(!mgr.health_check().await, "no active runtime = unhealthy");
}
#[tokio::test]
async fn test_switch_to_hermes_requires_binary() {
let mgr = AcpRuntimeManager::from_env();
// This might fail if `hermes` binary is not in PATH — that's OK for this test
let result = mgr.switch_to("hermes").await;
// We just verify it doesn't panic; either succeeds or returns Spawn error
if let Err(e) = &result {
assert!(
matches!(e, AcpError::Spawn(_)),
"expected Spawn error if hermes not in PATH, got: {e}"
);
} else {
// Success — clean up
mgr.shutdown_active().await;
}
}
}
@@ -0,0 +1,537 @@
/// 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<String> },
/// Connection closed/error.
Disconnected { reason: String },
}
/// Handler for session events.
pub type SessionEventHandler = Arc<dyn Fn(AcpSessionEvent) + Send + Sync + 'static>;
// ── 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<AcpClient>,
session_id: Arc<Mutex<Option<String>>>,
state: Arc<Mutex<SessionState>>,
event_handler: Arc<Mutex<Option<SessionEventHandler>>>,
/// Accumulated text for deduplication (per-turn).
accumulated: Arc<Mutex<String>>,
/// Whether we're currently inside a prompt (for dedup gating).
in_prompt: Arc<Mutex<bool>>,
}
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<AcpClient>) -> Self {
let session_id: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let state: Arc<Mutex<SessionState>> = Arc::new(Mutex::new(SessionState::Idle));
let event_handler: Arc<Mutex<Option<SessionEventHandler>>> =
Arc::new(Mutex::new(None));
let accumulated: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
let in_prompt: Arc<Mutex<bool>> = 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<F>(&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<Value>,
) -> Result<String, crate::acp_client::AcpError> {
let params = SessionNewParams {
cwd: cwd.map(|s| s.to_string()),
mcp_servers: None,
};
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<ContentBlock>,
) -> Result<SessionPromptResult, crate::acp_client::AcpError> {
{
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(|| {
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: SessionPromptResult = self.client.request("session/prompt", params).await?;
debug!(
"ACP session/prompt done (session={}, stop_reason={:?})",
session_id, result.stop_reason
);
{
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();
}
Ok(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<String> {
self.session_id.lock().unwrap().clone()
}
/// Get the current session state.
pub async fn state(&self) -> SessionState {
self.state.lock().unwrap().clone()
}
// ── 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<Mutex<String>>,
is_in_prompt: bool,
) -> Option<AcpSessionEvent> {
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<String> =
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<AcpClient> {
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");
}
}
+556
View File
@@ -0,0 +1,556 @@
/// ACP (Agent Client Protocol) type definitions.
///
/// Strongly-typed Rust representations of the ACP JSON-RPC 2.0 messages.
/// Both Hermes (`hermes acp`) and Reasonix share this protocol shape.
///
/// Reference:
/// - `reference-code/DeepSeek-Reasonix-main/src/acp/protocol.ts`
/// - `reference-code/hermes-vscode-main/src/protocol.ts`
use serde::{Deserialize, Serialize};
use serde_json::Value;
// ── JSON-RPC 2.0 basics ──────────────────────────────
pub type JsonRpcId = serde_json::Value; // number or string
// ── Initialize ───────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InitializeParams {
pub protocol_version: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_capabilities: Option<ClientCapabilities>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_info: Option<ClientInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub fs: Option<FsCapabilities>,
#[serde(skip_serializing_if = "Option::is_none")]
pub terminal: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FsCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub read_text_file: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub write_text_file: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientInfo {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InitializeResult {
pub protocol_version: u64,
pub agent_capabilities: AgentCapabilities,
pub agent_info: AgentInfo,
pub auth_methods: Vec<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub load_session: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_capabilities: Option<PromptCapabilities>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mcp_capabilities: Option<McpCapabilities>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PromptCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audio: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub embedded_context: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub http: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sse: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentInfo {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub version: String,
}
// ── Session ──────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionNewParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mcp_servers: Option<Vec<McpServerSpec>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpServerSpec {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub args: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub env: Option<std::collections::HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionNewResult {
pub session_id: String,
}
// ── Content blocks ───────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ContentBlock {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "resource")]
Resource {
resource: ResourceContent,
},
#[serde(rename = "image")]
Image {
mime_type: String,
data: String,
},
#[serde(rename = "audio")]
Audio {
mime_type: String,
data: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResourceContent {
pub uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
}
// ── Session prompt ───────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionPromptParams {
pub session_id: String,
pub prompt: Vec<ContentBlock>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionPromptResult {
pub stop_reason: StopReason,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StopReason {
EndTurn,
ToolUseComplete,
Cancelled,
Error,
}
// ── Session cancel (notification, no result) ─────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionCancelParams {
pub session_id: String,
}
// ── Session update (notification from agent to client) ──
/// The `session/update` notification payload.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionUpdateParams {
pub session_id: String,
pub update: SessionUpdate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum SessionUpdate {
AgentMessageChunk {
#[serde(rename = "sessionUpdate")]
session_update: String, // "agent_message_chunk"
content: TextContent,
},
AgentThoughtChunk {
#[serde(rename = "sessionUpdate")]
session_update: String, // "agent_thought_chunk"
content: TextContent,
},
ToolCall {
#[serde(rename = "sessionUpdate")]
session_update: String, // "tool_call"
#[serde(rename = "toolCallId")]
tool_call_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
kind: Option<ToolCallKind>,
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<ToolCallStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
raw_input: Option<Value>,
},
ToolCallUpdate {
#[serde(rename = "sessionUpdate")]
session_update: String, // "tool_call_update"
#[serde(rename = "toolCallId")]
tool_call_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<ToolCallStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<Vec<ContentBlockWrapper>>,
},
Plan {
#[serde(rename = "sessionUpdate")]
session_update: String, // "plan"
entries: Vec<PlanEntry>,
},
UsageUpdate {
#[serde(rename = "sessionUpdate")]
session_update: String, // "usage_update"
used: u64,
size: u64,
},
SessionInfoUpdate {
#[serde(rename = "sessionUpdate")]
session_update: String, // "session_info_update"
title: String,
},
/// Catch-all for any future/unknown session update variants.
Unknown {
#[serde(rename = "sessionUpdate")]
session_update: String,
#[serde(flatten)]
extra: std::collections::HashMap<String, Value>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextContent {
#[serde(rename = "type")]
pub content_type: String, // "text"
pub text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContentBlockWrapper {
#[serde(rename = "type")]
pub wrapper_type: String, // "content"
pub content: TextContent,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolCallKind {
Read,
Edit,
Search,
Execute,
Other,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolCallStatus {
Pending,
InProgress,
Completed,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlanEntry {
pub content: String,
pub priority: PlanPriority,
pub status: PlanEntryStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlanPriority {
High,
Medium,
Low,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlanEntryStatus {
Pending,
InProgress,
Completed,
}
// ── Permission request (from agent to client) ────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionRequestParams {
pub session_id: String,
pub tool_call: PermissionToolCall,
pub options: Vec<PermissionOption>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionToolCall {
#[serde(rename = "toolCallId")]
pub tool_call_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<ToolCallKind>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<ToolCallStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_input: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionOption {
pub option_id: String,
pub name: String,
pub kind: PermissionOptionKind,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PermissionOptionKind {
AllowOnce,
AllowAlways,
RejectOnce,
RejectAlways,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionRequestResult {
pub outcome: PermissionOutcome,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PermissionOutcome {
Selected { outcome: String, option_id: String },
Cancelled { outcome: String },
}
// ── Error codes (JSON-RPC standard) ──────────────────
pub const ERR_PARSE: i64 = -32700;
pub const ERR_INVALID_REQUEST: i64 = -32600;
pub const ERR_METHOD_NOT_FOUND: i64 = -32601;
pub const ERR_INVALID_PARAMS: i64 = -32602;
pub const ERR_INTERNAL: i64 = -32603;
// ── Session update kind discriminants ────────────────
/// Constants for the `sessionUpdate` string field.
impl SessionUpdate {
pub const AGENT_MESSAGE_CHUNK: &'static str = "agent_message_chunk";
pub const AGENT_THOUGHT_CHUNK: &'static str = "agent_thought_chunk";
pub const TOOL_CALL: &'static str = "tool_call";
pub const TOOL_CALL_UPDATE: &'static str = "tool_call_update";
pub const PLAN: &'static str = "plan";
pub const USAGE_UPDATE: &'static str = "usage_update";
pub const SESSION_INFO_UPDATE: &'static str = "session_info_update";
}
/// Parse the `sessionUpdate` string field from a raw JSON value and return the discriminant.
pub fn session_update_kind<'a>(value: &'a serde_json::Value) -> Option<&'a str> {
value
.get("update")
.and_then(|u| u.get("sessionUpdate"))
.and_then(|v| v.as_str())
}
// ── Helper: extract text from agent_message_chunk / agent_thought_chunk ──
/// Extract the text content from a session update's content block.
/// Returns `None` for non-text updates or malformed content.
///
/// Reference: `hermes-vscode-main/src/protocol.ts` `extractTextContent()`
pub fn extract_text_from_update(update: &SessionUpdate) -> Option<&str> {
match update {
SessionUpdate::AgentMessageChunk { content, .. }
| SessionUpdate::AgentThoughtChunk { content, .. } => Some(&content.text),
_ => None,
}
}
// ── Tests ────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_initialize_params_roundtrip() {
let params = InitializeParams {
protocol_version: 1,
client_capabilities: None,
client_info: Some(ClientInfo {
name: "mnote-web".into(),
title: Some("MNote".into()),
version: Some("0.1.0".into()),
}),
};
let json = serde_json::to_value(&params).unwrap();
assert_eq!(json["protocolVersion"], 1);
assert_eq!(json["clientInfo"]["name"], "mnote-web");
let deserialized: InitializeParams = serde_json::from_value(json).unwrap();
assert_eq!(deserialized.protocol_version, 1);
}
#[test]
fn test_session_new_params() {
let params = SessionNewParams {
cwd: Some("/mnt/Data1T/mnote".into()),
mcp_servers: None,
};
let json = serde_json::to_value(&params).unwrap();
assert_eq!(json["cwd"], "/mnt/Data1T/mnote");
}
#[test]
fn test_session_update_agent_message_chunk() {
let json = serde_json::json!({
"sessionId": "test_1",
"update": {
"sessionUpdate": "agent_message_chunk",
"content": { "type": "text", "text": "Hello" }
}
});
let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap();
assert_eq!(parsed.session_id, "test_1");
match &parsed.update {
SessionUpdate::AgentMessageChunk { content, .. } => {
assert_eq!(content.text, "Hello");
}
_ => panic!("expected AgentMessageChunk"),
}
}
#[test]
fn test_session_update_tool_call() {
let json = serde_json::json!({
"sessionId": "test_1",
"update": {
"sessionUpdate": "tool_call",
"toolCallId": "tc_1",
"title": "mnote.doc.fetch",
"kind": "read",
"status": "pending"
}
});
let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap();
match &parsed.update {
SessionUpdate::ToolCall { title, kind, .. } => {
assert_eq!(title.as_deref(), Some("mnote.doc.fetch"));
assert!(matches!(kind, Some(ToolCallKind::Read)));
}
_ => panic!("expected ToolCall"),
}
}
#[test]
fn test_session_update_usage() {
let json = serde_json::json!({
"sessionId": "test_1",
"update": {
"sessionUpdate": "usage_update",
"used": 1500,
"size": 4000
}
});
let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap();
match &parsed.update {
SessionUpdate::UsageUpdate { used, size, .. } => {
assert_eq!(*used, 1500);
assert_eq!(*size, 4000);
}
_ => panic!("expected UsageUpdate"),
}
}
#[test]
fn test_content_block_text() {
let block = ContentBlock::Text { text: "hello".into() };
let json = serde_json::to_value(&block).unwrap();
assert_eq!(json["type"], "text");
assert_eq!(json["text"], "hello");
}
#[test]
fn test_flatten_prompt() {
let blocks = vec![
ContentBlock::Text { text: "Hello".into() },
ContentBlock::Resource {
resource: ResourceContent {
uri: "file:///test.md".into(),
mime_type: None,
text: Some(" world".into()),
},
},
];
// flattenPrompt equivalent: concatenate text blocks + resource text
let text: Vec<String> = blocks
.iter()
.map(|b| match b {
ContentBlock::Text { text } => text.clone(),
ContentBlock::Resource { resource } => resource.text.clone().unwrap_or_default(),
_ => String::new(),
})
.collect();
assert_eq!(text.join(""), "Hello world");
}
}
+5
View File
@@ -1,5 +1,10 @@
#![recursion_limit = "1024"]
pub mod acp_bridge;
pub mod acp_client;
pub mod acp_runtime;
pub mod acp_session_manager;
pub mod acp_types;
pub mod app;
pub mod context;
pub mod editor_actor;
@@ -1,9 +1,11 @@
use crate::acp_types::ContentBlock;
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::manifest;
use crate::transport::convex::execute_convex_query_by_name;
use axum::body::Body;
use tokio::sync::broadcast;
use axum::extract::{Extension, Path, Query, State};
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::Response;
@@ -15,10 +17,10 @@ use std::collections::{HashMap, HashSet, VecDeque};
use std::fs;
use std::path::{Path as FsPath, PathBuf};
use std::process::Command;
use std::sync::{LazyLock, Mutex};
use std::sync::{Arc, LazyLock, Mutex};
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::warn;
use tracing::{info, warn};
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_HERMES_CLIENT_OWNER: &str = "x-mnote-hermes-client-owner";
@@ -27,6 +29,9 @@ static HERMES_RUNTIME_REGISTRY: LazyLock<Mutex<HashMap<String, HermesRuntimeStat
LazyLock::new(|| Mutex::new(HashMap::new()));
static HERMES_RUN_QUEUE: LazyLock<Mutex<HashMap<String, VecDeque<HermesQueuedRun>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Store ACP run payloads keyed by run_id, so stream_events can read them.
static ACP_RUN_PAYLOADS: LazyLock<Mutex<HashMap<String, Value>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
#[derive(Debug, Clone)]
struct HermesRuntimeState {
@@ -522,6 +527,27 @@ pub async fn create_run(
let (actor_id, actor_type) = resolve_run_actor(&state, &context).await;
stamp_run_actor(&mut payload, &actor_id, &actor_type);
let registration = run_registration_from_payload(&context, &payload);
// ACP path: skip the HTTP proxy, just register and return run info
if is_acp_profile(&registration.profile) {
let run_id = registration.session_id.clone(); // session_id serves as run_id
let runtime_state = register_acp_runtime(&registration);
// Store payload for stream_events to use
ACP_RUN_PAYLOADS
.lock()
.expect("acp run payloads")
.insert(run_id.clone(), payload.clone());
let response = json!({
"ok": true,
"runId": run_id,
"sessionId": registration.session_id,
"profile": registration.profile,
"traceId": context.trace.trace_id,
"runtime": runtime_state,
});
return Ok((StatusCode::OK, stamp_client_headers(), Json(response)));
}
if session_has_active_run(&registration.session_id) {
let queued = enqueue_run(&context, &registration, &payload)?;
return Ok((StatusCode::ACCEPTED, stamp_client_headers(), Json(queued)));
@@ -567,12 +593,148 @@ pub async fn cancel_queued_run(
))
}
/// ACP variant of stream_events: creates an ACP session, runs the prompt,
/// and returns an SSE stream of events.
async fn acp_stream_events(
state: AppState,
context: RequestContext,
run_id: &str,
profile: &str,
) -> Result<Response, WebError> {
// Get the stored payload from create_run
let payload = ACP_RUN_PAYLOADS
.lock()
.expect("acp run payloads")
.remove(run_id)
.ok_or_else(|| {
WebError::bad_gateway_code(
"acp_run_payload_not_found",
format!("ACP run payload not found for run_id={run_id}"),
)
.with_context(&context)
})?;
// Build prompt from the message field (frontend sends "message", not "input")
let input = payload
.get("message")
.or_else(|| payload.get("input"))
.and_then(Value::as_str)
.unwrap_or("请读取当前文档内容");
let prompt_blocks = vec![ContentBlock::Text {
text: input.to_string(),
}];
let runtime_name = crate::acp_bridge::runtime_name_for_profile(profile);
// Ensure runtime is active; switch_to either activates it or returns existing
let client = state.acp_runtime.switch_to(runtime_name).await.map_err(|e| {
WebError::bad_gateway_code(
"acp_runtime_switch_failed",
format!("Failed to activate ACP runtime '{runtime_name}': {e}"),
)
.with_context(&context)
})?;
// Create session manager and start the prompt
let mgr = Arc::new(crate::acp_session_manager::AcpSessionManager::new(client));
let (event_tx, _event_rx) = broadcast::channel(256);
let event_tx_clone = event_tx.clone();
mgr.on_event(move |event| {
if let Some(sse) = crate::acp_bridge::acp_event_to_sse(event) {
let _ = event_tx_clone.send(sse);
}
});
mgr.create_session(None, None)
.await
.map_err(|e| {
WebError::bad_gateway_code(
"acp_session_create_failed",
format!("ACP session creation failed: {e}"),
)
.with_context(&context)
})?;
// Run prompt in background
let run_id_owned = run_id.to_string();
let mgr_clone = Arc::clone(&mgr);
let event_tx_prompt = event_tx.clone();
update_runtime_by_run_id(&run_id_owned, "running", Some("acp.prompt.started"), None);
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(crate::acp_bridge::SseEvent {
event: "run.completed".into(),
data: json!({
"stopReason": format!("{:?}", result.stop_reason),
}),
});
}
Err(e) => {
warn!("ACP prompt failed: {e}");
let _ = event_tx_prompt.send(crate::acp_bridge::SseEvent {
event: "run.failed".into(),
data: json!({ "error": e.to_string() }),
});
}
}
update_runtime_by_run_id(&run_id_owned, "completed", Some("acp.prompt.done"), None);
});
// Build SSE response from event channel
use tokio::sync::mpsc;
let (tx, rx) = mpsc::channel::<Result<axum::body::Bytes, std::convert::Infallible>>(256);
let mut event_rx = event_tx.subscribe();
tokio::spawn(async move {
loop {
match event_rx.recv().await {
Ok(event) => {
let json_str = serde_json::to_string(&event.data).unwrap_or_else(|_| "{}".into());
let bytes = axum::body::Bytes::from(
format!("event: {}\ndata: {}\n\n", event.event, json_str)
);
if tx.send(Ok(bytes)).await.is_err() { break; }
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
warn!("ACP SSE lagged: {n} events dropped");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
let mut response = 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))
.map_err(|e| {
WebError::internal(format!("SSE response build failed: {e}"))
.with_context(&context)
})?;
stamp_client_headers_into(response.headers_mut());
Ok(response)
}
pub async fn stream_events(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path(run_id): Path<String>,
) -> Result<Response, WebError> {
ensure_authenticated(&context)?;
let profile = profile_for_run(&run_id).unwrap_or_else(|| "default".into());
// ACP path: start AcpRunBridge and return SSE stream
if is_acp_profile(&profile) {
return acp_stream_events(state, context, &run_id, &profile).await;
}
let Some(upstream) = configured_upstream_for_profile(&profile) else {
return Err(hermes_unconfigured_error(&context));
};
@@ -856,9 +1018,28 @@ fn list_profiles_payload() -> Value {
.map(|stdout| parse_profile_list(&stdout))
.filter(|profiles| !profiles.is_empty())
.unwrap_or_else(fallback_profiles);
let reasonix_model = std::env::var("REASONIX_MODEL").unwrap_or_else(|_| "deepseek-chat".into());
let reasonix_preset = std::env::var("REASONIX_PRESET").unwrap_or_else(|_| "auto".into());
let reasonix_has_key = std::env::var("DEEPSEEK_API_KEY").is_ok();
json!({
"ok": true,
"profiles": profiles
"profiles": profiles,
"acpRuntimes": json!([
{
"name": "hermes",
"title": "ACP · Hermes",
"description": "通过 ACP 协议直连 Hermes agent runtime · model/default 来自 Hermes profile"
},
{
"name": "reasonix",
"title": "ACP · Reasonix",
"description": "通过 ACP 协议直连 ReasonixDeepSeek 缓存优先)",
"model": reasonix_model,
"preset": reasonix_preset,
"apiKeyConfigured": reasonix_has_key,
"version": "0.43.0"
}
])
})
}
@@ -1472,6 +1653,41 @@ fn profile_env_key(prefix: &str, profile: &str, suffix: &str) -> String {
format!("{prefix}_{normalized}_{suffix}")
}
/// Returns true if the given profile should use ACP instead of Hermes HTTP proxy.
///
/// Profile names `reasonix` always use ACP. Other profiles can be configured
/// via `MNOTE_WEB_<PROFILE>_RUNTIME_TYPE=acp`.
/// Default profiles (`default`, `hermes`) use the traditional Hermes HTTP proxy.
fn is_acp_profile(profile: &str) -> bool {
// ACP runtimes: "reasonix" and "hermes" both use ACP protocol when selected from the UI.
// The "reasonix" name is hardcoded; "hermes" as ACP is triggered by env var or UI selection.
if profile == "reasonix" || profile == "hermes" {
return true;
}
let env_key = profile_env_key("MNOTE_WEB", profile, "RUNTIME_TYPE");
env_or_dotenv(&env_key)
.map(|v| v.trim().to_lowercase() == "acp")
.unwrap_or(false)
}
/// Returns the ACP runtime name for a profile.
/// For ACP profiles, returns the runtime backend name ("hermes" or "reasonix").
/// The profile name is used as the runtime name unless overridden by env var.
#[allow(dead_code)]
fn configured_runtime_for_profile(profile: &str) -> Option<String> {
if !is_acp_profile(profile) {
return None;
}
if profile == "reasonix" {
return Some("reasonix".into());
}
let env_key = profile_env_key("MNOTE_WEB", profile, "RUNTIME_NAME");
let name = env_or_dotenv(&env_key)
.filter(|v| !v.trim().is_empty())
.map(|v| v.trim().to_lowercase());
Some(name.unwrap_or_else(|| profile.to_lowercase()))
}
fn configured_upstream_for_profile(profile: &str) -> Option<String> {
let profile = profile.trim();
if !profile.is_empty() && profile != "default" {
@@ -2050,6 +2266,33 @@ fn run_registration_from_payload(
}
}
/// Register a runtime state from a registration (without upstream response).
/// Used by the ACP path where no Hermes HTTP upstream exists.
fn register_acp_runtime(registration: &HermesRunRegistration) -> Value {
let now = now_ms();
let run_id = registration.session_id.clone(); // use session_id as run_id for ACP
let state = HermesRuntimeState {
session_id: registration.session_id.clone(),
run_id,
profile: registration.profile.clone(),
document_id: registration.document_id.clone(),
trace_id: registration.trace_id.clone(),
status: "acp_pending".into(),
started_at: now,
last_event_at: now,
last_event: Some("run.started".into()),
last_tool_name: None,
last_tool_call_id: None,
last_audit_id: None,
};
let json = runtime_state_to_json(&state);
HERMES_RUNTIME_REGISTRY
.lock()
.expect("hermes runtime registry")
.insert(registration.session_id.clone(), state);
json
}
fn register_runtime_from_create_run_response(
registration: &HermesRunRegistration,
payload: &Value,
@@ -194,6 +194,7 @@ pub async fn mnote_call(
"mnote.block.delete" => block::block_delete(&state, &context, &input).await,
"mnote.block.move_after" => block::block_move_after(&state, &context, &input).await,
"mnote.doc.apply_block_ops" => block::doc_apply_block_ops(&state, &context, &input).await,
"mnote.doc.markdown_edit" => doc::doc_markdown_edit(&state, &context, &input).await,
"mnote.page.get" => page::page_get(&state, &context, &input).await,
"mnote.page.save" => page::page_save(&state, &context, &input).await,
"mnote.page.update_title" => page::update_title(&state, &context, &input).await,
+26 -1
View File
@@ -4846,6 +4846,30 @@ const SIDEBAR_TREE_JS: &str = r##"
if (profileLabel instanceof HTMLElement) {
profileLabel.style.display = isAcp ? 'none' : '';
}
// When ACP is selected, populate agent panel with ACP runtime info
var agentPanel = drawer.querySelector('[data-page-ai-agent-panel]');
if (agentPanel instanceof HTMLElement) {
if (isAcp) {
var rt = pageUiState.pageAiAcpRuntimes.find(function(r) { return r.name === pageUiState.pageAiAcpRuntime; }) || {};
var keyStatus = rt.apiKeyConfigured ? '' : ' DEEPSEEK_API_KEY';
agentPanel.innerHTML = '' +
'<section class="wolai-page-ai-memory-card">' +
'<div class="wolai-page-ai-memory-head"><div class="wolai-page-ai-memory-title">' + escapeHtml(rt.title || 'ACP Runtime') + '</div></div>' +
'</section>' +
'<section class="wolai-page-ai-memory-card">' +
'<div class="wolai-page-ai-memory-head"><div class="wolai-page-ai-memory-title"></div><div class="wolai-page-ai-memory-scope">' + escapeHtml(rt.model || 'deepseek-chat') + '</div></div>' +
'</section>' +
'<section class="wolai-page-ai-memory-card">' +
'<div class="wolai-page-ai-memory-head"><div class="wolai-page-ai-memory-title">Preset</div><div class="wolai-page-ai-memory-scope">' + escapeHtml(rt.preset || 'auto') + '</div></div>' +
'</section>' +
'<section class="wolai-page-ai-memory-card">' +
'<div class="wolai-page-ai-memory-head"><div class="wolai-page-ai-memory-title">API Key</div><div class="wolai-page-ai-memory-scope">' + escapeHtml(keyStatus) + '</div></div>' +
'</section>' +
'<section class="wolai-page-ai-memory-card">' +
'<div class="wolai-page-ai-memory-head"><div class="wolai-page-ai-memory-title"></div><div class="wolai-page-ai-memory-scope">' + escapeHtml(rt.description || '') + '</div></div>' +
'</section>';
}
}
var profileSummary = drawer.querySelector('[data-page-ai-profile-summary]');
if (profileSummary instanceof HTMLElement) profileSummary.textContent = activeProfile;
var profileSummary = drawer.querySelector('[data-page-ai-profile-summary]');
@@ -4927,7 +4951,8 @@ const SIDEBAR_TREE_JS: &str = r##"
memoryError.hidden = !memoryErrorText;
}
var agentPanel = drawer.querySelector('[data-page-ai-agent-panel]');
if (agentPanel instanceof HTMLElement) {
if (agentPanel instanceof HTMLElement && !isAcp) {
// Only populate Hermes memory when NOT in ACP mode (ACP handles this earlier in the function)
agentPanel.innerHTML = ['soul', 'user', 'memory'].map(function(section) {
var label = pageAiMemoryFileLabel(section);
var value = pageUiState.pageAiProfileMemoryDrafts[section] || '';