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
+648 -41
View File
@@ -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(&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");
}
}