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:
lix-2026
2026-05-21 05:40:06 +08:00
parent a29d9868f6
commit eba1010191
50 changed files with 4309 additions and 283 deletions
+166 -26
View File
@@ -80,6 +80,17 @@ type NotificationHandler = Box<dyn Fn(String, Value) + Send + 'static>;
/// Thread-safe mutex for notification handler (std mutex — lightweight, never held across awaits).
type NotificationHandlerMutex = std::sync::Mutex<Option<NotificationHandler>>;
// ── Incoming request handler type ────────────────────
///
/// agent 发送 JSON-RPC request(同时包含 `id` 与 `method`)时调用。
/// 返回 `true` 表示 handler 已负责稍后响应;返回 `false` 则由 dispatch_message
/// 直接回复 method-not-found。handler 应通过 [`AcpClient::respond_to_incoming`]
/// 或 [`AcpClient::respond_to_incoming_error`] 回写响应。
type IncomingRequestHandler = Box<dyn Fn(Value, String, Value) -> bool + Send + 'static>;
/// incoming request handler 的线程安全容器。
type IncomingRequestHandlerMutex = std::sync::Mutex<Option<IncomingRequestHandler>>;
// ── Pending request entry ────────────────────────────
type PendingEntry = oneshot::Sender<Result<Value, AcpError>>;
@@ -108,6 +119,9 @@ pub struct AcpClient {
pending: Arc<Mutex<HashMap<u64, PendingEntry>>>,
next_id: AtomicU64,
notification_handler: Arc<NotificationHandlerMutex>,
/// agent 发来的 incoming JSON-RPC request handler(同时有 id 和 method)。
/// 未设置时会直接回复 method-not-found。
incoming_request_handler: Arc<IncomingRequestHandlerMutex>,
}
impl AcpClient {
@@ -162,14 +176,24 @@ impl AcpClient {
let pending: Arc<Mutex<HashMap<u64, PendingEntry>>> = Arc::new(Mutex::new(HashMap::new()));
let notification_handler: Arc<NotificationHandlerMutex> =
Arc::new(std::sync::Mutex::new(None));
let incoming_request_handler: Arc<IncomingRequestHandlerMutex> =
Arc::new(std::sync::Mutex::new(None));
// Start background reader task
let pending_clone = pending.clone();
let handler_clone = notification_handler.clone();
let incoming_clone = incoming_request_handler.clone();
let writer_clone = writer.clone();
let child_pid = child.id().unwrap_or(0);
tokio::spawn(async move {
Self::reader_loop(reader, writer_clone, pending_clone, handler_clone).await;
Self::reader_loop(
reader,
writer_clone,
pending_clone,
handler_clone,
incoming_clone,
)
.await;
info!("ACP reader loop ended (pid={})", child_pid);
});
@@ -179,6 +203,7 @@ impl AcpClient {
pending,
next_id: AtomicU64::new(1),
notification_handler,
incoming_request_handler,
};
// Handshake: initialize (reference: acpClient.ts L108 → call('initialize', {protocolVersion: 1}))
@@ -272,6 +297,53 @@ impl AcpClient {
*guard = Some(Box::new(handler));
}
/// 注册 incoming JSON-RPC request handler(消息同时包含 `id` 与 `method`)。
/// handler 接收原始 request id、method 和 params,并应稍后通过
/// [`respond_to_incoming`] 或 [`respond_to_incoming_error`] 响应。
/// 同一时间只保留一个 handler,后续注册会覆盖前一个。
pub fn on_incoming_request<F>(&self, handler: F)
where
F: Fn(Value, String, Value) -> bool + Send + 'static,
{
let mut guard = self.incoming_request_handler.lock().unwrap();
*guard = Some(Box::new(handler));
}
/// 用 result 响应 agent 发来的 incoming JSON-RPC request。
///
/// 必须使用 incoming request handler 收到的原始 `id`,避免丢失字符串 id。
pub async fn respond_to_incoming(&self, id: Value, result: Value) -> Result<(), AcpError> {
let msg = json!({
"jsonrpc": "2.0",
"id": id.clone(),
"result": result,
});
debug!("ACP <-- respond to incoming #{}", id);
Self::write_jsonrpc_message(&self.writer, &msg).await
}
/// 用 error 响应 incoming JSON-RPC request。
pub async fn respond_to_incoming_error(
&self,
id: Value,
code: i64,
message: &str,
) -> Result<(), AcpError> {
let msg = json!({
"jsonrpc": "2.0",
"id": id.clone(),
"error": {
"code": code,
"message": message,
},
});
debug!(
"ACP <-- respond error to incoming #{}: [{}] {}",
id, code, message
);
Self::write_jsonrpc_message(&self.writer, &msg).await
}
/// Gracefully close the ACP connection and kill the subprocess.
pub async fn close(&mut self) {
if let Some(mut child) = self.child.take() {
@@ -296,6 +368,7 @@ impl AcpClient {
writer: Arc<Mutex<BufWriter<ChildStdin>>>,
pending: Arc<Mutex<HashMap<u64, PendingEntry>>>,
notification_handler: Arc<NotificationHandlerMutex>,
incoming_request_handler: Arc<IncomingRequestHandlerMutex>,
) {
let mut line_buf = String::new();
loop {
@@ -328,7 +401,14 @@ impl AcpClient {
}
};
Self::dispatch_message(msg, &writer, &pending, &notification_handler).await;
Self::dispatch_message(
msg,
&writer,
&pending,
&notification_handler,
&incoming_request_handler,
)
.await;
}
// Process died or EOF — resolve all pending
@@ -346,6 +426,7 @@ impl AcpClient {
writer: &Arc<Mutex<BufWriter<ChildStdin>>>,
pending: &Arc<Mutex<HashMap<u64, PendingEntry>>>,
notification_handler: &Arc<NotificationHandlerMutex>,
incoming_request_handler: &Arc<IncomingRequestHandlerMutex>,
) {
let has_id = msg.get("id").is_some();
let has_method = msg
@@ -355,33 +436,36 @@ impl AcpClient {
.unwrap_or(false);
if has_id && has_method {
// Incoming request from agent (e.g. session/request_permission)
// agent 发来的 incoming request,例如 session/request_permission
let method = msg["method"].as_str().unwrap_or("unknown").to_string();
let params = msg.get("params").cloned().unwrap_or(Value::Null);
let id = msg.get("id").cloned().unwrap_or(Value::Null);
// 当前还没有权限确认 UI,必须明确拒绝,避免 agent 等待到超时。
warn!("ACP incoming request not handled: {method} (params={params:?})");
let response = json!({
"jsonrpc": "2.0",
"id": id,
"error": {
"code": -32601,
"message": format!("ACP incoming request not supported: {method}")
}
});
if let Err(error) = Self::write_jsonrpc_message(writer, &response).await {
warn!("ACP incoming request response write failed: {error}");
}
if method == "session/request_permission" {
let mut event_params = params;
if let Some(object) = event_params.as_object_mut() {
object.insert("decision".into(), Value::String("denied".into()));
object.insert("method".into(), Value::String(method.clone()));
object.insert("jsonrpcId".into(), id);
}
let handler_guard = notification_handler.lock().unwrap();
let id_val = msg.get("id").cloned().unwrap_or(Value::Null);
// 若已注册 handler,则由 handler 决定是否负责稍后响应。
let handled = {
let handler_guard = incoming_request_handler.lock().unwrap();
if let Some(ref handler) = *handler_guard {
handler(method, event_params);
handler(id_val.clone(), method.clone(), params.clone())
} else {
false
}
};
if handled {
debug!("ACP incoming request dispatched: {method} #{}", id_val);
} else {
// 没有 handler 时必须立即响应,避免 agent 一直等待。
warn!("ACP incoming request not handled (no handler registered): {method}");
let response = json!({
"jsonrpc": "2.0",
"id": id_val,
"error": {
"code": -32601,
"message": format!("ACP incoming request not supported: {method}")
}
});
if let Err(error) = Self::write_jsonrpc_message(writer, &response).await {
warn!("ACP incoming request response write failed: {error}");
}
}
} else if has_id {
@@ -528,6 +612,48 @@ rl.on('line', (line) => {
.expect("spawn permission mock ACP")
}
async fn spawn_string_id_permission_request_mock_server() -> AcpClient {
let script = r#"
import * as readline from 'node:readline';
import { stdin as input, stdout as output } from 'node:process';
let permissionResponse = null;
const rl = readline.createInterface({ input, output, terminal: false });
function send(msg) { process.stdout.write(JSON.stringify(msg) + '\n'); }
rl.on('line', (line) => {
const msg = JSON.parse(line);
if (msg.id !== undefined && msg.method === 'initialize') {
send({
jsonrpc: '2.0',
id: msg.id,
result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] }
});
setTimeout(() => send({
jsonrpc: '2.0',
id: 'perm-string-id',
method: 'session/request_permission',
params: { reason: 'test permission' }
}), 10);
} else if (msg.id === 'perm-string-id' && msg.method === undefined) {
permissionResponse = msg;
} else if (msg.id !== undefined && msg.method === 'get_permission_response') {
send({
jsonrpc: '2.0',
id: msg.id,
result: { permissionResponse }
});
}
});
"#;
let dir = std::env::temp_dir();
let script_path = dir.join("acp_permission_request_string_id_mock.mjs");
std::fs::write(&script_path, script).expect("write permission string id mock script");
AcpClient::spawn("node", &[script_path.to_str().unwrap()])
.await
.expect("spawn permission string id mock ACP")
}
#[tokio::test]
async fn test_request_response() {
let client = spawn_mock_acp_server().await;
@@ -603,4 +729,18 @@ rl.on('line', (line) => {
assert_eq!(response["id"], 77);
assert!(response.get("result").is_some() || response.get("error").is_some());
}
#[tokio::test]
async fn test_incoming_permission_request_preserves_string_id() {
let client = spawn_string_id_permission_request_mock_server().await;
tokio::time::sleep(Duration::from_millis(100)).await;
let result: Value = client
.request("get_permission_response", json!({}))
.await
.expect("permission response probe");
let response = &result["permissionResponse"];
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], "perm-string-id");
assert!(response.get("result").is_some() || response.get("error").is_some());
}
}