Files
mnote/rust/crates/mnote-web/src/acp_session_manager.rs
T

1235 lines
46 KiB
Rust
Raw Normal View History

2026-05-17 16:15:52 +08:00
/// 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::{
2026-05-21 05:40:06 +08:00
ContentBlock, ContentBlockWrapper, SessionLoadParams, SessionNewParams, SessionNewResult,
SessionPromptParams, SessionPromptResult, SessionUpdate, ToolCallStatus,
2026-05-17 16:15:52 +08:00
};
use serde_json::{json, Value};
2026-05-21 05:40:06 +08:00
use std::collections::HashMap;
2026-05-17 16:15:52 +08:00
use std::sync::{Arc, Mutex};
2026-05-21 05:40:06 +08:00
use std::time::Duration;
use tokio::time::sleep;
2026-05-17 16:15:52 +08:00
use tracing::{debug, info, warn};
// ── 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 },
2026-05-21 05:40:06 +08:00
/// 工具调用开始。
2026-05-17 16:15:52 +08:00
ToolCall {
tool_call_id: String,
title: String,
kind: String,
status: ToolCallStatus,
2026-05-17 21:21:59 +08:00
raw_input: Option<Value>,
2026-05-21 05:40:06 +08:00
/// 工具涉及的文件路径。
locations: Vec<String>,
2026-05-17 16:15:52 +08:00
},
2026-05-21 05:40:06 +08:00
/// 工具调用状态更新,可能携带结果内容。
2026-05-17 16:15:52 +08:00
ToolCallUpdate {
tool_call_id: String,
status: ToolCallStatus,
2026-05-17 21:21:59 +08:00
content: Option<Vec<ContentBlockWrapper>>,
2026-05-17 16:15:52 +08:00
},
2026-05-21 05:40:06 +08:00
/// 上下文用量更新。
2026-05-17 16:15:52 +08:00
UsageUpdate { used: u64, size: u64 },
2026-05-21 05:40:06 +08:00
/// Agent 发起权限请求。
PermissionRequest {
permission_id: String,
tool_name: String,
params: Value,
decision: String,
},
2026-05-21 05:40:06 +08:00
/// 会话元数据更新,例如自动标题。
2026-05-17 16:15:52 +08:00
SessionInfoUpdate { title: String },
2026-05-21 05:40:06 +08:00
/// 计划条目更新。
2026-05-17 16:15:52 +08:00
PlanUpdate { entries: Vec<String> },
2026-05-21 05:40:06 +08:00
/// 连接关闭或异常。
2026-05-17 16:15:52 +08:00
Disconnected { reason: String },
}
#[derive(Debug, Clone, Default)]
pub struct AcpMnoteToolContext {
pub mnote_session_id: Option<String>,
pub run_id: Option<String>,
pub actor_id: Option<String>,
pub trace_id: Option<String>,
pub workspace_id: Option<String>,
pub document_id: Option<String>,
}
2026-05-17 16:15:52 +08:00
/// 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,
}
2026-05-21 05:40:06 +08:00
// ── Pending permission ───────────────────────────────
/// 等待前端决策的 `session/request_permission`。
#[derive(Debug, Clone)]
pub struct PendingPermission {
/// incoming ACP request 的原始 JSON-RPC id,可能是数字或字符串。
pub jsonrpc_id: Value,
/// `session/request_permission` 的原始参数。
pub params: Value,
/// 请求创建时间,用于后续超时判断。
pub created_at: std::time::Instant,
}
2026-05-17 16:15:52 +08:00
// ── 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>>,
2026-05-21 05:40:06 +08:00
/// Pending permission requests keyed by permission_id.
pending_permissions: Arc<Mutex<HashMap<String, PendingPermission>>>,
2026-05-17 16:15:52 +08:00
}
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));
2026-05-17 20:11:39 +08:00
let event_handler: Arc<Mutex<Option<SessionEventHandler>>> = Arc::new(Mutex::new(None));
2026-05-17 16:15:52 +08:00
let accumulated: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
let in_prompt: Arc<Mutex<bool>> = Arc::new(Mutex::new(false));
2026-05-21 05:40:06 +08:00
let pending_permissions: Arc<Mutex<HashMap<String, PendingPermission>>> =
Arc::new(Mutex::new(HashMap::new()));
2026-05-17 16:15:52 +08:00
2026-05-21 05:40:06 +08:00
// ── Incoming request handler ──────────────────
// 处理 `session/request_permission`:先进入 pending,再由 HTTP
// resolve-permission 端点决定最终响应。
let event_handler_for_incoming = event_handler.clone();
let pending_for_incoming = pending_permissions.clone();
let client_for_incoming = client.clone();
client.on_incoming_request(move |id, method, params| {
if method != "session/request_permission" {
// 未知方法交回 dispatch_message 回复 method-not-found。
return false;
}
// 优先使用 agent 传来的 permission id;缺失时用 JSON-RPC id 派生稳定 id。
let permission_id = params
.get("permissionId")
.or_else(|| params.get("permission_id"))
.or_else(|| params.get("requestId"))
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("acp_perm_{}", id));
let tool_name = params
.get("toolName")
.or_else(|| params.get("tool"))
.or_else(|| params.get("name"))
.or_else(|| params.get("method"))
.and_then(Value::as_str)
.unwrap_or("session/request_permission")
.to_string();
// 记录到 pending map,等待前端 allow / deny。
{
let mut pending = pending_for_incoming.lock().unwrap();
pending.insert(
permission_id.clone(),
PendingPermission {
jsonrpc_id: id.clone(),
params: params.clone(),
created_at: std::time::Instant::now(),
},
);
debug!(
"ACP permission pending: {} (jsonrpc_id={}, tool={})",
permission_id, id, tool_name
);
}
// 向前端发出 `permission.requested`。
{
let handler = event_handler_for_incoming.lock().unwrap();
if let Some(ref h) = *handler {
h(AcpSessionEvent::PermissionRequest {
permission_id: permission_id.clone(),
tool_name,
params,
decision: "requested".into(),
});
}
}
// 60 秒没有决策时自动 deny,避免 agent 永久等待。
let pending_for_timeout = pending_for_incoming.clone();
let event_handler_for_timeout = event_handler_for_incoming.clone();
let client_for_timeout = client_for_incoming.clone();
let timeout_permission_id = permission_id.clone();
let timeout_jsonrpc_id = id.clone();
tokio::spawn(async move {
sleep(Duration::from_secs(60)).await;
let should_deny = {
let mut pending = pending_for_timeout.lock().unwrap();
if pending.remove(&timeout_permission_id).is_some() {
true
} else {
false
}
};
if should_deny {
warn!(
"ACP permission timeout: {} (jsonrpc_id={}), auto-denying",
timeout_permission_id, timeout_jsonrpc_id
);
// 向 ACP 回复 permission denied。
let _ = client_for_timeout
.respond_to_incoming_error(
timeout_jsonrpc_id.clone(),
-32000,
"permission denied by timeout",
)
.await;
// 同步 denied 事件给前端。
let handler = event_handler_for_timeout.lock().unwrap();
if let Some(ref h) = *handler {
h(AcpSessionEvent::PermissionRequest {
permission_id: timeout_permission_id,
tool_name: "session/request_permission".into(),
params: Value::Null,
decision: "denied".into(),
});
}
}
});
true // handler took responsibility for responding
});
// ── Notification handler ──────────────────────
// Only handles `session/update` (streaming events from agent).
// Incoming request methods like `session/request_permission` are handled
// above via `on_incoming_request`.
2026-05-17 16:15:52 +08:00
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,
2026-05-21 05:40:06 +08:00
pending_permissions,
2026-05-17 16:15:52 +08:00
}
}
/// 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));
}
2026-05-21 05:40:06 +08:00
/// 按用户决策解析 pending permission。
///
/// 根据 `permission_id` 找到 pending request,向 ACP 子进程回写 result/error
/// 发出 `permission.allowed` / `permission.denied` 事件,并从 pending map 移除。
///
/// permission 存在且完成响应时返回 `Ok(())`,否则返回错误原因。
pub async fn resolve_permission(
&self,
permission_id: &str,
decision: &str,
) -> Result<(), String> {
let pending = {
let mut map = self.pending_permissions.lock().unwrap();
map.remove(permission_id)
};
let pending = pending.ok_or_else(|| {
format!("pending permission not found: {permission_id} (already timed out or invalid)")
})?;
let tool_name = pending
.params
.get("toolName")
.or_else(|| pending.params.get("tool"))
.or_else(|| pending.params.get("name"))
.and_then(Value::as_str)
.unwrap_or("session/request_permission")
.to_string();
let normalized_decision = match decision {
"allow" | "allowed" => "allow",
"deny" | "denied" => "deny",
other => return Err(format!("unknown decision: {other} (expected allow/deny)")),
};
let response = permission_response_for_decision(&pending.params, normalized_decision)
.unwrap_or_else(|| {
if normalized_decision == "allow" {
Err((-32000, "permission allow option not available".into()))
} else {
Err((-32000, "permission denied by user".into()))
}
});
match response {
Ok(result) => {
self.client
.respond_to_incoming(pending.jsonrpc_id.clone(), result)
.await
.map_err(|e| format!("ACP respond failed: {e}"))?;
info!(
"ACP permission resolved: {} decision={} (jsonrpc_id={})",
permission_id, normalized_decision, pending.jsonrpc_id
);
}
Err((code, message)) => {
self.client
.respond_to_incoming_error(pending.jsonrpc_id.clone(), code, &message)
.await
.map_err(|e| format!("ACP respond failed: {e}"))?;
info!(
"ACP permission rejected: {} decision={} (jsonrpc_id={})",
permission_id, normalized_decision, pending.jsonrpc_id
);
}
}
// 发出最终 decision 事件。
{
let handler = self.event_handler.lock().unwrap();
if let Some(ref h) = *handler {
h(AcpSessionEvent::PermissionRequest {
permission_id: permission_id.to_string(),
tool_name,
params: pending.params,
decision: if normalized_decision == "allow" {
"allowed".into()
} else {
"denied".into()
},
});
}
}
Ok(())
}
2026-05-17 16:15:52 +08:00
/// 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> {
2026-05-17 20:11:39 +08:00
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();
2026-05-17 16:15:52 +08:00
let params = SessionNewParams {
2026-05-17 20:11:39 +08:00
cwd: Some(cwd.map(str::to_string).unwrap_or(project_root)),
mcp_servers: Some(Vec::new()),
2026-05-17 16:15:52 +08:00
};
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)
}
2026-05-21 05:40:06 +08:00
/// Load/resume an existing ACP session.
///
/// Sends `session/load { sessionId, cwd, mcpServers: [] }` to the agent.
/// Returns `true` if the session was loaded and the internal session_id updated,
/// `false` if the adapter returned null (session not found) or doesn't support the method.
///
/// On success the internal `session_id` is set to the loaded id.
///
/// Reference: `hermes-vscode-main/src/sessionManager.ts` `ensureSession()`
pub async fn load_session(
&self,
session_id: &str,
cwd: Option<&str>,
) -> Result<bool, 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 = SessionLoadParams {
session_id: session_id.to_string(),
cwd: Some(cwd.map(str::to_string).unwrap_or(project_root)),
mcp_servers: Some(Vec::new()),
};
match self
.client
.request::<_, serde_json::Value>("session/load", params)
.await
{
Ok(result) => {
// Successful load: adapter returned { sessionId: "..." }
if let Some(sid) = result.get("sessionId").and_then(|v| v.as_str()) {
if !sid.is_empty() {
let mut sid_guard = self.session_id.lock().unwrap();
*sid_guard = Some(sid.to_string());
info!("ACP session loaded: {}", sid);
return Ok(true);
}
}
// Null or missing sessionId → session not found on adapter
info!(
"ACP session/load returned null for session_id={}",
session_id
);
Ok(false)
}
Err(crate::acp_client::AcpError::JsonRpc { code, message }) => {
// Adapter doesn't support session/load or session expired
info!(
"ACP session/load not supported (code={code}, message={message}), will fallback"
);
Ok(false)
}
Err(e) => {
warn!("ACP session/load transport error: {e}");
Err(e)
}
}
}
/// Ensure an active ACP session exists.
///
/// 1. If there's already an active session, return it immediately.
/// 2. If a `stored_session_id` is provided, attempt `session/load` first.
/// 3. Fall back to `session/new`.
///
/// Reference: `hermes-vscode-main/src/sessionManager.ts` `ensureSession()`
pub async fn ensure_session(
&self,
cwd: Option<&str>,
stored_session_id: Option<&str>,
) -> Result<String, crate::acp_client::AcpError> {
// 1. Reuse active session if one exists
{
let sid = self.session_id.lock().unwrap();
if let Some(ref sid) = *sid {
debug!("ACP ensure_session: reusing active session {}", sid);
return Ok(sid.clone());
}
}
// 2. Try to load a stored session
if let Some(stored) = stored_session_id {
if !stored.is_empty() {
debug!("ACP ensure_session: attempting session/load for {}", stored);
if self.load_session(stored, cwd).await.unwrap_or(false) {
return Ok(stored.to_string());
}
info!(
"ACP ensure_session: stored session {} not found, creating new",
stored
);
}
}
// 3. Fallback: create new session
debug!("ACP ensure_session: creating new session");
self.create_session(cwd, None).await
}
2026-05-17 16:15:52 +08:00
/// 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> {
self.run_prompt_with_mnote_context(prompt, None).await
}
pub async fn run_prompt_with_mnote_context(
&self,
prompt: Vec<ContentBlock>,
mnote_context: Option<AcpMnoteToolContext>,
2026-05-17 16:15:52 +08:00
) -> 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(|| {
2026-05-17 20:11:39 +08:00
self.reset_prompt_state();
2026-05-17 16:15:52 +08:00
crate::acp_client::AcpError::Internal(
"no session created yet — call create_session first".into(),
)
})?;
let mnote_context = mnote_context.unwrap_or_default();
2026-05-17 16:15:52 +08:00
let params = SessionPromptParams {
session_id: session_id.clone(),
prompt,
mnote_session_id: mnote_context.mnote_session_id,
run_id: mnote_context.run_id,
actor_id: mnote_context.actor_id,
trace_id: mnote_context.trace_id,
workspace_id: mnote_context.workspace_id,
document_id: mnote_context.document_id,
2026-05-17 16:15:52 +08:00
};
debug!("ACP session/prompt (session={})", session_id);
2026-05-17 20:11:39 +08:00
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();
2026-05-17 16:15:52 +08:00
2026-05-17 20:11:39 +08:00
result
2026-05-17 16:15:52 +08:00
}
/// 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();
2026-05-17 20:11:39 +08:00
let session_id =
sid.ok_or_else(|| crate::acp_client::AcpError::Internal("no active session".into()))?;
2026-05-17 16:15:52 +08:00
{
let mut state = self.state.lock().unwrap();
*state = SessionState::Cancelling;
}
self.client
2026-05-17 20:11:39 +08:00
.notification("session/cancel", json!({ "sessionId": session_id }))
2026-05-17 16:15:52 +08:00
.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()
}
2026-05-17 20:11:39 +08:00
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();
}
}
2026-05-17 16:15:52 +08:00
// ── 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,
2026-05-17 21:21:59 +08:00
raw_input,
2026-05-21 05:40:06 +08:00
locations,
2026-05-17 16:15:52 +08:00
..
} => {
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);
2026-05-21 05:40:06 +08:00
let locations: Vec<String> = locations.iter().map(|l| l.path.clone()).collect();
2026-05-17 16:15:52 +08:00
Some(AcpSessionEvent::ToolCall {
tool_call_id: tool_call_id.clone(),
title,
kind: kind_str,
status,
2026-05-17 21:21:59 +08:00
raw_input: raw_input.clone(),
2026-05-21 05:40:06 +08:00
locations,
2026-05-17 16:15:52 +08:00
})
}
SessionUpdate::ToolCallUpdate {
tool_call_id,
status,
2026-05-17 21:21:59 +08:00
content,
2026-05-17 16:15:52 +08:00
..
} => {
let status = status.clone().unwrap_or(ToolCallStatus::Completed);
Some(AcpSessionEvent::ToolCallUpdate {
tool_call_id: tool_call_id.clone(),
status,
2026-05-17 21:21:59 +08:00
content: content.clone(),
2026-05-17 16:15:52 +08:00
})
}
2026-05-17 20:11:39 +08:00
SessionUpdate::UsageUpdate { used, size, .. } => Some(AcpSessionEvent::UsageUpdate {
used: *used,
size: *size,
}),
2026-05-17 16:15:52 +08:00
SessionUpdate::SessionInfoUpdate { title, .. } => {
Some(AcpSessionEvent::SessionInfoUpdate {
title: title.clone(),
})
}
SessionUpdate::Plan { entries, .. } => {
2026-05-17 20:11:39 +08:00
let summaries: Vec<String> = entries.iter().map(|e| e.content.clone()).collect();
2026-05-17 16:15:52 +08:00
Some(AcpSessionEvent::PlanUpdate { entries: summaries })
}
SessionUpdate::Unknown { .. } => {
warn!("ACP unknown session/update variant");
None
}
}
}
}
2026-05-21 05:40:06 +08:00
fn permission_response_for_decision(
params: &Value,
decision: &str,
) -> Option<Result<Value, (i64, String)>> {
let option_id = permission_option_id_by_decision(params, decision);
match (decision, option_id) {
("allow", Some(option_id)) | ("deny", Some(option_id)) => Some(Ok(json!({
"outcome": "selected",
"optionId": option_id
}))),
("deny", None) => Some(Err((-32000, "permission denied by user".into()))),
_ => None,
}
}
fn permission_option_id_by_decision(params: &Value, decision: &str) -> Option<String> {
let options = params.get("options").and_then(Value::as_array)?;
let preferred: &[&str] = if decision == "allow" {
&["allow_once", "allow", "approve", "yes"]
} else {
&["deny_once", "reject_once", "deny", "reject", "no"]
};
for keyword in preferred.iter().copied() {
if let Some(option_id) = options.iter().find_map(|option| {
let id = permission_option_id(option)?;
let haystack = format!(
"{} {}",
id.to_ascii_lowercase(),
option
.get("kind")
.and_then(Value::as_str)
.unwrap_or("")
.to_ascii_lowercase()
);
if haystack.contains(keyword) {
Some(id)
} else {
None
}
}) {
return Some(option_id);
}
}
if decision == "allow" {
return options.iter().find_map(|option| {
let id = permission_option_id(option)?;
let lower = id.to_ascii_lowercase();
if lower.contains("deny") || lower.contains("reject") || lower == "no" {
None
} else {
Some(id)
}
});
}
None
}
fn permission_option_id(option: &Value) -> Option<String> {
option
.get("optionId")
.or_else(|| option.get("option_id"))
.or_else(|| option.get("id"))
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned)
}
2026-05-17 16:15:52 +08:00
// ── 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");
2026-05-17 20:11:39 +08:00
assert_eq!(format!("{:?}", result.stop_reason), "EndTurn".to_string());
2026-05-17 16:15:52 +08:00
// 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;
2026-05-17 20:11:39 +08:00
assert!(
received.load(Ordering::SeqCst),
"should have received TextDelta"
);
}
2026-05-21 05:40:06 +08:00
/// Creates a mock ACP that supports session/load (success case).
async fn spawn_mock_acp_with_load() -> 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/load') {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0', id: msg.id,
result: { sessionId: msg.params?.sessionId || 'loaded_session_1' }
}) + '\n');
} else 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_load_success_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)
}
/// Creates a mock ACP that returns null for session/load (session expired/not found)
async fn spawn_mock_acp_with_load_null() -> 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/load') {
// Return null result → session not found on adapter
process.stdout.write(JSON.stringify({
jsonrpc: '2.0', id: msg.id,
result: null
}) + '\n');
} else if (msg.method === 'session/new') {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0', id: msg.id,
result: { sessionId: 'fallback_session_1' }
}) + '\n');
} else if (msg.method === 'session/prompt') {
const sessionId = msg.params?.sessionId || 'fallback_session_1';
process.stdout.write(JSON.stringify({
jsonrpc: '2.0',
method: 'session/update',
params: {
sessionId,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Hello from fallback session' }
}
}
}) + '\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_load_null_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_load_session_success() {
let client = spawn_mock_acp_with_load().await;
let mgr = AcpSessionManager::new(client);
let loaded = mgr
.load_session("stored_session_1", Some("/test"))
.await
.expect("load_session");
assert!(loaded, "session should be loaded successfully");
assert_eq!(mgr.session_id().await, Some("stored_session_1".into()));
}
#[tokio::test]
async fn test_load_session_null_fallback() {
let client = spawn_mock_acp_with_load_null().await;
let mgr = AcpSessionManager::new(client);
// load_session returns false for null result
let loaded = mgr
.load_session("expired_session", Some("/test"))
.await
.expect("load_session");
assert!(!loaded, "session should not be loaded");
// No session_id set after failed load
assert_eq!(mgr.session_id().await, None);
}
#[tokio::test]
async fn test_ensure_session_reuses_active() {
let client = spawn_mock_acp().await;
let mgr = AcpSessionManager::new(client);
// First, create a session
let created = mgr
.create_session(Some("/test"), None)
.await
.expect("create_session");
assert_eq!(created, "test_session_1");
// ensure_session should reuse the active session, not create a new one
let ensured = mgr
.ensure_session(Some("/test"), Some("stored_but_ignored"))
.await
.expect("ensure_session");
assert_eq!(ensured, "test_session_1");
assert_eq!(mgr.session_id().await, Some("test_session_1".into()));
}
#[tokio::test]
async fn test_ensure_session_loads_stored() {
let client = spawn_mock_acp_with_load().await;
let mgr = AcpSessionManager::new(client);
// No active session, stored id provided → should load
let ensured = mgr
.ensure_session(Some("/test"), Some("stored_session_1"))
.await
.expect("ensure_session");
assert_eq!(ensured, "stored_session_1");
assert_eq!(mgr.session_id().await, Some("stored_session_1".into()));
}
#[tokio::test]
async fn test_ensure_session_fallback_to_new() {
let client = spawn_mock_acp_with_load_null().await;
let mgr = AcpSessionManager::new(client);
// No active session, stored id provided but load returns null → fallback to new
let ensured = mgr
.ensure_session(Some("/test"), Some("expired_session"))
.await
.expect("ensure_session");
assert_eq!(ensured, "fallback_session_1");
assert_eq!(mgr.session_id().await, Some("fallback_session_1".into()));
}
#[tokio::test]
async fn test_ensure_session_no_stored_creates_new() {
let client = spawn_mock_acp().await;
let mgr = AcpSessionManager::new(client);
// No active session, no stored id → should create new
let ensured = mgr
.ensure_session(Some("/test"), None)
.await
.expect("ensure_session");
assert_eq!(ensured, "test_session_1");
assert_eq!(mgr.session_id().await, Some("test_session_1".into()));
}
#[tokio::test]
async fn test_ensure_session_fallback_then_prompt_works() {
let client = spawn_mock_acp_with_load_null().await;
let mgr = AcpSessionManager::new(client);
// ensure_session with expired stored id → should create fallback
mgr.ensure_session(Some("/test"), Some("expired_session"))
.await
.expect("ensure_session");
// Verify prompt still works on the fallback session
let prompt = vec![ContentBlock::Text {
text: "Hello after fallback".into(),
}];
let result = mgr
.run_prompt(prompt)
.await
.expect("run_prompt after fallback");
assert_eq!(format!("{:?}", result.stop_reason), "EndTurn".to_string());
}
2026-05-17 20:11:39 +08:00
#[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:?}"),
}
2026-05-17 16:15:52 +08:00
}
2026-05-21 05:40:06 +08:00
#[test]
fn permission_response_selects_allow_and_deny_options() {
let params = json!({
"options": [
{"optionId": "allow_once", "name": "Allow Once", "kind": "allow_once"},
{"optionId": "reject_once", "name": "Reject Once", "kind": "reject_once"}
]
});
let allow = permission_response_for_decision(&params, "allow")
.expect("allow response")
.expect("allow should select an option");
assert_eq!(allow["outcome"], "selected");
assert_eq!(allow["optionId"], "allow_once");
let deny = permission_response_for_decision(&params, "deny")
.expect("deny response")
.expect("deny should select an option");
assert_eq!(deny["outcome"], "selected");
assert_eq!(deny["optionId"], "reject_once");
}
2026-05-17 16:15:52 +08:00
}