feat: stabilize page AI ACP runtimes

实现并稳定页面 AI 的 ACP Hermes / ACP Reasonix 运行路径。

主要内容:

- 分离 profile 与 acpRuntime,ACP Hermes 按所选 Hermes profile 启动并注入 provider key。

- 修复 Reasonix ACP wrapper 的 API key 读取、ToolRegistry 注册、LoopEvent role 映射和 reasoning/final 分流。

- 修复 ACP agent_thought_chunk 被 untagged enum 误解析为 message.delta 的问题,补充 thought 相关单测。

- 补充页面 AI 浏览器验证 skill 证据到 7-15 设计稿,并记录严格验收标准。

- 同步提交当前仓库中已存在的 rust-web / Hermes tools / SSE / bug 文档相关改动。

验证:

- node --check scripts/reasonix-acp-wrapper.mjs

- cargo test -p mnote-web acp -- --nocapture

- 页面 AI ACP 浏览器验证:tmp/page-ai-acp-browser-UAYwyM/
This commit is contained in:
lix-2026
2026-05-17 20:11:39 +08:00
parent 2ea559beaa
commit bb2f190f50
19 changed files with 1307 additions and 451 deletions
+39 -19
View File
@@ -12,7 +12,6 @@
/// 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};
@@ -21,7 +20,7 @@ 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::sync::{oneshot, Mutex};
use tokio::time::{timeout, Duration};
use tracing::{debug, info, warn};
@@ -120,27 +119,40 @@ impl AcpClient {
///
/// 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)
Self::spawn_with_env(bin, args, None).await
}
/// Spawn an ACP subprocess with extra environment variables.
pub async fn spawn_with_env(
bin: &str,
args: &[&str],
env_overrides: Option<&HashMap<String, String>>,
) -> Result<Self, AcpError> {
let mut command = Command::new(bin);
command
.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)?;
.kill_on_drop(true);
if let Some(env) = env_overrides {
command.envs(env);
}
let mut child = command.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 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 pending: Arc<Mutex<HashMap<u64, PendingEntry>>> = Arc::new(Mutex::new(HashMap::new()));
let notification_handler: Arc<NotificationHandlerMutex> =
Arc::new(std::sync::Mutex::new(None));
@@ -182,7 +194,8 @@ impl AcpClient {
method: &str,
params: P,
) -> Result<R, AcpError> {
self.request_with_timeout(method, params, Duration::from_secs(300)).await
self.request_with_timeout(method, params, Duration::from_secs(300))
.await
}
/// Same as [`request`] but with a configurable timeout.
@@ -298,7 +311,10 @@ impl AcpClient {
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)]);
warn!(
"ACP parse error: {e} (line: {})",
&trimmed[..trimmed.len().min(80)]
);
continue;
}
};
@@ -322,7 +338,11 @@ impl AcpClient {
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);
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)
@@ -458,14 +478,14 @@ rl.on('line', (line) => {
}
});
// Send a notification that the mock server will echo back as...
// 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