chore: move 3-14 WS push design to done/
This commit is contained in:
Generated
+13
@@ -1544,6 +1544,7 @@ dependencies = [
|
||||
"storage-convex-bridge",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-tungstenite",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -2688,6 +2689,18 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.29.0"
|
||||
|
||||
@@ -17,7 +17,8 @@ reqwest = { version = "0.12", default-features = false, features = ["blocking",
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
storage-convex-bridge = { path = "../storage-convex-bridge" }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time", "process", "io-util"] }
|
||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
tokio-tungstenite = "0.29"
|
||||
tower-http = { version = "0.6", features = ["trace"] }
|
||||
tracing = "0.1"
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, ¬ification_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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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(¶ms).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(¶ms).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");
|
||||
}
|
||||
}
|
||||
@@ -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(®istration.profile) {
|
||||
let run_id = registration.session_id.clone(); // session_id serves as run_id
|
||||
let runtime_state = register_acp_runtime(®istration);
|
||||
// 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(®istration.session_id) {
|
||||
let queued = enqueue_run(&context, ®istration, &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 协议直连 Reasonix(DeepSeek 缓存优先)",
|
||||
"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,
|
||||
|
||||
@@ -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] || '';
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"rustc_fingerprint":9228011546279038255,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.89.0 (29483883e 2025-08-04)\nbinary: rustc\ncommit-hash: 29483883eed69d5fb4db01964cdf2af4d86e9cb2\ncommit-date: 2025-08-04\nhost: x86_64-unknown-linux-gnu\nrelease: 1.89.0\nLLVM version: 20.1.7\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""}},"successes":{}}
|
||||
{"rustc_fingerprint":10059341515723286937,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.95.0 (59807616e 2026-04-14)\nbinary: rustc\ncommit-hash: 59807616e1fa2540724bfbac14d7976d7e4a3860\ncommit-date: 2026-04-14\nhost: x86_64-unknown-linux-gnu\nrelease: 1.95.0\nLLVM version: 22.1.2\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/stable-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""}},"successes":{}}
|
||||
Reference in New Issue
Block a user