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
@@ -6,11 +6,10 @@
/// 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,
ContentBlock, SessionNewParams, SessionNewResult, SessionPromptParams, SessionPromptResult,
SessionUpdate, ToolCallStatus,
};
use serde_json::{json, Value};
use std::sync::{Arc, Mutex};
@@ -89,8 +88,7 @@ impl AcpSessionManager {
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 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));
@@ -164,9 +162,15 @@ impl AcpSessionManager {
cwd: Option<&str>,
_page_context: Option<Value>,
) -> Result<String, crate::acp_client::AcpError> {
let project_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(3)
.unwrap_or(std::path::Path::new("/mnt/Data1T/mnote"))
.to_string_lossy()
.to_string();
let params = SessionNewParams {
cwd: cwd.map(|s| s.to_string()),
mcp_servers: None,
cwd: Some(cwd.map(str::to_string).unwrap_or(project_root)),
mcp_servers: Some(Vec::new()),
};
let result: SessionNewResult = self.client.request("session/new", params).await?;
@@ -203,6 +207,7 @@ impl AcpSessionManager {
let sid = self.session_id.lock().unwrap().clone();
let session_id = sid.ok_or_else(|| {
self.reset_prompt_state();
crate::acp_client::AcpError::Internal(
"no session created yet — call create_session first".into(),
)
@@ -214,26 +219,17 @@ impl AcpSessionManager {
};
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 result: Result<SessionPromptResult, crate::acp_client::AcpError> =
self.client.request("session/prompt", params).await;
if let Ok(ref prompt_result) = result {
debug!(
"ACP session/prompt done (session={}, stop_reason={:?})",
session_id, prompt_result.stop_reason
);
}
self.reset_prompt_state();
{
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)
result
}
/// Cancel the current prompt.
@@ -241,9 +237,8 @@ impl AcpSessionManager {
/// 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 session_id =
sid.ok_or_else(|| crate::acp_client::AcpError::Internal("no active session".into()))?;
{
let mut state = self.state.lock().unwrap();
@@ -251,10 +246,7 @@ impl AcpSessionManager {
}
self.client
.notification(
"session/cancel",
json!({ "sessionId": session_id }),
)
.notification("session/cancel", json!({ "sessionId": session_id }))
.await?;
info!("ACP session cancelled: {}", session_id);
@@ -285,6 +277,21 @@ impl AcpSessionManager {
self.state.lock().unwrap().clone()
}
fn reset_prompt_state(&self) {
{
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();
}
}
// ── Internal: session/update → event mapping ─────
/// Convert a parsed [`SessionUpdate`] into an [`AcpSessionEvent`],
@@ -382,12 +389,10 @@ impl AcpSessionManager {
})
}
SessionUpdate::UsageUpdate { used, size, .. } => {
Some(AcpSessionEvent::UsageUpdate {
used: *used,
size: *size,
})
}
SessionUpdate::UsageUpdate { used, size, .. } => Some(AcpSessionEvent::UsageUpdate {
used: *used,
size: *size,
}),
SessionUpdate::SessionInfoUpdate { title, .. } => {
Some(AcpSessionEvent::SessionInfoUpdate {
@@ -396,8 +401,7 @@ impl AcpSessionManager {
}
SessionUpdate::Plan { entries, .. } => {
let summaries: Vec<String> =
entries.iter().map(|e| e.content.clone()).collect();
let summaries: Vec<String> = entries.iter().map(|e| e.content.clone()).collect();
Some(AcpSessionEvent::PlanUpdate { entries: summaries })
}
@@ -490,10 +494,7 @@ rl.on('line', (line) => {
text: "Hello agent".into(),
}];
let result = mgr.run_prompt(prompt).await.expect("run_prompt");
assert_eq!(
format!("{:?}", result.stop_reason),
"EndTurn".to_string()
);
assert_eq!(format!("{:?}", result.stop_reason), "EndTurn".to_string());
// After prompt, state should be idle again
assert_eq!(mgr.state().await, SessionState::Idle);
}
@@ -532,6 +533,29 @@ rl.on('line', (line) => {
// Give the notification handler time to process
sleep(Duration::from_millis(200)).await;
assert!(received.load(Ordering::SeqCst), "should have received TextDelta");
assert!(
received.load(Ordering::SeqCst),
"should have received TextDelta"
);
}
#[test]
fn test_thought_chunk_maps_to_thought_delta() {
let accumulated = Arc::new(Mutex::new(String::new()));
let update = SessionUpdate::AgentThoughtChunk {
session_update: SessionUpdate::AGENT_THOUGHT_CHUNK.into(),
content: crate::acp_types::TextContent {
content_type: "text".into(),
text: "internal reasoning".into(),
},
};
let event = AcpSessionManager::session_update_to_event(&update, &accumulated, true)
.expect("thought chunk should emit event");
match event {
AcpSessionEvent::ThoughtDelta { text } => assert_eq!(text, "internal reasoning"),
other => panic!("expected ThoughtDelta, got {other:?}"),
}
}
}