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:
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user