fix local office resource editing
- add local-folder OnlyOffice sign/callback writeback and edit-tab handling - align main resource tabs, attachment edit menu, slash isolation, and filetree context behavior - record Sidex/Hermes gap reviews and Reasonix task checklists
This commit is contained in:
@@ -8,16 +8,16 @@
|
||||
/// - `reference-code/hermes-vscode-main/src/protocol.ts` (dedup logic)
|
||||
use crate::acp_client::AcpClient;
|
||||
use crate::acp_types::{
|
||||
ContentBlock, ContentBlockWrapper, SessionNewParams, SessionNewResult, SessionPromptParams,
|
||||
SessionPromptResult, SessionUpdate, ToolCallStatus,
|
||||
ContentBlock, ContentBlockWrapper, SessionLoadParams, SessionNewParams, SessionNewResult,
|
||||
SessionPromptParams, SessionPromptResult, SessionUpdate, ToolCallStatus,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
#[cfg(test)]
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
// ── Events ───────────────────────────────────────────
|
||||
|
||||
/// Strongly-typed event emitted by the session manager when a `session/update` arrives.
|
||||
@@ -27,34 +27,36 @@ pub enum AcpSessionEvent {
|
||||
TextDelta { text: String },
|
||||
/// Streaming reasoning/thinking text.
|
||||
ThoughtDelta { text: String },
|
||||
/// Tool call started.
|
||||
/// 工具调用开始。
|
||||
ToolCall {
|
||||
tool_call_id: String,
|
||||
title: String,
|
||||
kind: String,
|
||||
status: ToolCallStatus,
|
||||
raw_input: Option<Value>,
|
||||
/// 工具涉及的文件路径。
|
||||
locations: Vec<String>,
|
||||
},
|
||||
/// Tool call status update (with optional result content).
|
||||
/// 工具调用状态更新,可能携带结果内容。
|
||||
ToolCallUpdate {
|
||||
tool_call_id: String,
|
||||
status: ToolCallStatus,
|
||||
content: Option<Vec<ContentBlockWrapper>>,
|
||||
},
|
||||
/// Context usage update.
|
||||
/// 上下文用量更新。
|
||||
UsageUpdate { used: u64, size: u64 },
|
||||
/// Agent 发起权限请求;当前 runtime 会自动拒绝并同步给前端。
|
||||
/// Agent 发起权限请求。
|
||||
PermissionRequest {
|
||||
permission_id: String,
|
||||
tool_name: String,
|
||||
params: Value,
|
||||
decision: String,
|
||||
},
|
||||
/// Session metadata update (e.g. auto-title).
|
||||
/// 会话元数据更新,例如自动标题。
|
||||
SessionInfoUpdate { title: String },
|
||||
/// Plan entries update.
|
||||
/// 计划条目更新。
|
||||
PlanUpdate { entries: Vec<String> },
|
||||
/// Connection closed/error.
|
||||
/// 连接关闭或异常。
|
||||
Disconnected { reason: String },
|
||||
}
|
||||
|
||||
@@ -81,6 +83,19 @@ pub enum SessionState {
|
||||
Closed,
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
}
|
||||
|
||||
// ── AcpSessionManager ────────────────────────────────
|
||||
|
||||
/// Manages ACP sessions — create, prompt, cancel, and event dispatch.
|
||||
@@ -97,6 +112,8 @@ pub struct AcpSessionManager {
|
||||
accumulated: Arc<Mutex<String>>,
|
||||
/// Whether we're currently inside a prompt (for dedup gating).
|
||||
in_prompt: Arc<Mutex<bool>>,
|
||||
/// Pending permission requests keyed by permission_id.
|
||||
pending_permissions: Arc<Mutex<HashMap<String, PendingPermission>>>,
|
||||
}
|
||||
|
||||
impl AcpSessionManager {
|
||||
@@ -110,42 +127,125 @@ impl AcpSessionManager {
|
||||
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));
|
||||
let pending_permissions: Arc<Mutex<HashMap<String, PendingPermission>>> =
|
||||
Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
// Wire up the ACP notification handler
|
||||
// ── 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`.
|
||||
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/request_permission" {
|
||||
let permission_id = params
|
||||
.get("permissionId")
|
||||
.or_else(|| params.get("permission_id"))
|
||||
.or_else(|| params.get("requestId"))
|
||||
.or_else(|| params.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("permission_auto_denied")
|
||||
.to_string();
|
||||
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();
|
||||
let handler = event_handler_clone.lock().unwrap();
|
||||
if let Some(ref h) = *handler {
|
||||
h(AcpSessionEvent::PermissionRequest {
|
||||
permission_id,
|
||||
tool_name,
|
||||
params,
|
||||
decision: "denied".into(),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if method != "session/update" {
|
||||
return;
|
||||
}
|
||||
@@ -187,6 +287,7 @@ impl AcpSessionManager {
|
||||
event_handler,
|
||||
accumulated,
|
||||
in_prompt,
|
||||
pending_permissions,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +301,92 @@ impl AcpSessionManager {
|
||||
*guard = Some(Arc::new(handler));
|
||||
}
|
||||
|
||||
/// 按用户决策解析 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(())
|
||||
}
|
||||
|
||||
/// Create a new ACP session.
|
||||
///
|
||||
/// Sends `session/new` to the agent and stores the returned `sessionId`.
|
||||
@@ -228,6 +415,108 @@ impl AcpSessionManager {
|
||||
Ok(result.session_id)
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// Send a prompt to the agent and stream events.
|
||||
///
|
||||
/// The `prompt` is a list of content blocks (text + optional page context).
|
||||
@@ -424,6 +713,7 @@ impl AcpSessionManager {
|
||||
kind,
|
||||
status,
|
||||
raw_input,
|
||||
locations,
|
||||
..
|
||||
} => {
|
||||
let title = title.clone().unwrap_or_else(|| "tool".into());
|
||||
@@ -432,12 +722,14 @@ impl AcpSessionManager {
|
||||
None => "other".into(),
|
||||
};
|
||||
let status = status.clone().unwrap_or(ToolCallStatus::Pending);
|
||||
let locations: Vec<String> = locations.iter().map(|l| l.path.clone()).collect();
|
||||
Some(AcpSessionEvent::ToolCall {
|
||||
tool_call_id: tool_call_id.clone(),
|
||||
title,
|
||||
kind: kind_str,
|
||||
status,
|
||||
raw_input: raw_input.clone(),
|
||||
locations,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -479,6 +771,76 @@ impl AcpSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -605,6 +967,229 @@ rl.on('line', (line) => {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thought_chunk_maps_to_thought_delta() {
|
||||
let accumulated = Arc::new(Mutex::new(String::new()));
|
||||
@@ -624,4 +1209,26 @@ rl.on('line', (line) => {
|
||||
other => panic!("expected ThoughtDelta, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[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(¶ms, "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(¶ms, "deny")
|
||||
.expect("deny response")
|
||||
.expect("deny should select an option");
|
||||
assert_eq!(deny["outcome"], "selected");
|
||||
assert_eq!(deny["optionId"], "reject_once");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user