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
+74 -42
View File
@@ -4,7 +4,6 @@
/// 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;
@@ -59,9 +58,14 @@ impl AcpRunBridge {
) -> 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)?
runtime_mgr
.active_client()
.await
.ok_or(AcpBridgeError::NoActiveRuntime)?
} else {
runtime_mgr.switch_to(runtime_name).await
runtime_mgr
.switch_to(runtime_name)
.await
.map_err(|e| AcpBridgeError::SessionError(e.to_string()))?
};
@@ -80,7 +84,8 @@ impl AcpRunBridge {
});
// Create session
let sid = mgr.create_session(None, None)
let sid = mgr
.create_session(None, None)
.await
.map_err(|e| AcpBridgeError::SessionError(e.to_string()))?;
@@ -134,10 +139,12 @@ impl AcpRunBridge {
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)
);
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
}
@@ -175,33 +182,27 @@ impl AcpRunBridge {
/// 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::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,
}),
})
}
} => Some(SseEvent {
event: "tool.started".into(),
data: json!({
"tool": title,
"toolCallId": tool_call_id,
"kind": kind,
}),
}),
AcpSessionEvent::ToolCallUpdate {
tool_call_id,
status,
@@ -215,24 +216,21 @@ pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
}),
})
}
AcpSessionEvent::UsageUpdate { used, size } => {
Some(SseEvent {
event: "usage.updated".into(),
data: json!({ "used": used, "size": size }),
})
}
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 }),
})
}
AcpSessionEvent::Disconnected { reason } if reason == "session closed" => None,
AcpSessionEvent::Disconnected { reason } => Some(SseEvent {
event: "run.failed".into(),
data: json!({ "error": reason }),
}),
}
}
@@ -246,4 +244,38 @@ pub fn runtime_name_for_profile(profile: &str) -> &str {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn acp_normal_session_close_does_not_emit_failure() {
let event = AcpSessionEvent::Disconnected {
reason: "session closed".into(),
};
assert!(acp_event_to_sse(event).is_none());
}
#[test]
fn acp_unexpected_disconnect_emits_failure() {
let event = AcpSessionEvent::Disconnected {
reason: "transport lost".into(),
};
let sse = acp_event_to_sse(event).expect("unexpected disconnect should be forwarded");
assert_eq!(sse.event, "run.failed");
assert_eq!(sse.data["error"], "transport lost");
}
#[test]
fn acp_thought_delta_does_not_emit_message_delta() {
let event = AcpSessionEvent::ThoughtDelta {
text: "internal reasoning".into(),
};
let sse = acp_event_to_sse(event).expect("thought delta should be forwarded separately");
assert_eq!(sse.event, "thought.delta");
assert_eq!(sse.data["delta"], "internal reasoning");
}
}