use crate::acp_types::ContentBlock;
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::manifest;
use crate::transport::convex::{execute_convex_mutation_by_name, execute_convex_query_by_name};
use axum::body::Body;
use axum::extract::{Extension, Path, Query, State};
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::Response;
use axum::Json;
use control_plane::{AppendAiRuntimeEventInput, UpsertAiRuntimeRunInput};
use futures_util::TryStreamExt;
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::env;
use std::fs;
use std::hash::{Hash, Hasher};
use std::io::Write;
use std::path::{Path as FsPath, PathBuf};
use std::process::Command;
use std::sync::{Arc, LazyLock, Mutex};
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::broadcast;
use tracing::{info, warn};

const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_HERMES_CLIENT_OWNER: &str = "x-mnote-hermes-client-owner";
const ACP_RUNTIME_SQLITE_STORE: &str = "sqlite_acp_runtime_store";
const ACP_ABORT_NOTIFICATION_TIMEOUT_MS: u64 = 2_500;
const LOCAL_SHARE_GRANTS_JSON: &str = "/mnt/Data1T/Mnote_data/control-plane/share-grants.json";
const ENV_LOCAL_SHARE_GRANTS_FILE: &str = "MNOTE_SHARE_GRANTS_FILE";

static HERMES_RUNTIME_REGISTRY: LazyLock<Mutex<HashMap<String, HermesRuntimeState>>> =
    LazyLock::new(|| Mutex::new(HashMap::new()));
static HERMES_RUN_QUEUE: LazyLock<Mutex<HashMap<String, VecDeque<HermesQueuedRun>>>> =
    LazyLock::new(|| Mutex::new(HashMap::new()));
/// Store ACP run payloads keyed by run_id, so stream_events can read them.
static ACP_RUN_PAYLOADS: LazyLock<Mutex<HashMap<String, Value>>> =
    LazyLock::new(|| Mutex::new(HashMap::new()));
static ACP_ACTIVE_RUNS: LazyLock<Mutex<HashMap<String, AcpActiveRun>>> =
    LazyLock::new(|| Mutex::new(HashMap::new()));
static ACP_LOCAL_AUDIT_SNAPSHOTS: LazyLock<Mutex<HashMap<String, LocalAgentAuditSnapshot>>> =
    LazyLock::new(|| Mutex::new(HashMap::new()));

const LOCAL_AGENT_AUDIT_DIR: &str = "/mnt/Data1T/Mnote_data/control-plane/agent-audit";
const LOCAL_AGENT_AUDIT_JSONL: &str = "agent-audit.jsonl";

#[derive(Debug, Clone)]
struct HermesRuntimeState {
    session_id: String,
    run_id: String,
    profile: String,
    document_id: String,
    trace_id: String,
    status: String,
    started_at: u128,
    last_event_at: u128,
    last_event: Option<String>,
    last_tool_name: Option<String>,
    last_tool_call_id: Option<String>,
    last_audit_id: Option<String>,
}

#[derive(Debug, Clone)]
struct HermesRunRegistration {
    session_id: String,
    profile: String,
    document_id: String,
    trace_id: String,
}

#[derive(Debug, Clone)]
struct HermesQueuedRun {
    queue_id: String,
    session_id: String,
    profile: String,
    document_id: String,
    trace_id: String,
    actor_id: String,
    actor_type: String,
    input: String,
    context_summary: Value,
    queued_at: u128,
}

#[derive(Clone)]
struct AcpActiveRun {
    manager: Arc<crate::acp_session_manager::AcpSessionManager>,
    mnote_session_id: String,
    acp_session_id: String,
    event_tx: broadcast::Sender<crate::acp_bridge::SseEvent>,
}

#[derive(Debug, Clone)]
struct LocalAgentAuditFileSnapshot {
    size: u64,
    modified_ms: u128,
    hash: u64,
    markdown_content: Option<String>,
}

#[derive(Debug, Clone)]
struct LocalAgentAuditSnapshot {
    root_uri: String,
    files: BTreeMap<String, LocalAgentAuditFileSnapshot>,
}

#[derive(Debug, Clone)]
struct LocalShareGrant {
    share_id: String,
    owner_id: Option<String>,
    target_user_id: Option<String>,
    root_uri: Option<String>,
    permission: String,
    capabilities: HashSet<String>,
    allowed_resource_ids: Vec<String>,
    revoked: bool,
}

impl LocalShareGrant {
    fn permission_level(&self) -> &'static str {
        if local_share_permission_allows_write(&self.permission, &self.capabilities) {
            "shared_write"
        } else {
            "shared_read"
        }
    }

    fn share_context(&self) -> Value {
        json!({
            "shareId": self.share_id,
            "ownerId": self.owner_id,
            "targetUserId": self.target_user_id,
            "rootUri": self.root_uri,
            "permission": self.permission,
            "capabilities": self.capabilities.iter().cloned().collect::<Vec<_>>()
        })
    }

    fn allowed_file_paths(&self) -> Vec<String> {
        let Some(root_uri) = self.root_uri.as_deref() else {
            return Vec::new();
        };
        let Some(root_path) = file_root_uri_to_permission_path(root_uri) else {
            return Vec::new();
        };
        self.allowed_resource_ids
            .iter()
            .filter_map(|resource_id| local_resource_id_to_relative_path(resource_id))
            .map(|relative_path| {
                FsPath::new(&root_path)
                    .join(relative_path)
                    .to_string_lossy()
                    .to_string()
            })
            .collect()
    }
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateSessionRequest {
    workspace_id: Option<String>,
    document_id: Option<String>,
    source_kind: Option<String>,
    root_uri: Option<String>,
    share_id: Option<String>,
    permission_level: Option<String>,
    trace_id: Option<String>,
    title: Option<String>,
    profile: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RenameSessionRequest {
    title: String,
}

pub async fn list_sessions(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Query(query): Query<HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let profile = query
        .get("profile")
        .map(String::as_str)
        .unwrap_or("default");
    if is_acp_profile(profile) || matches!(query.get("source").map(String::as_str), Some("acp")) {
        return list_acp_sessions(&state, &context, &query).await;
    }
    let Some(upstream) = configured_upstream_for_profile(profile) else {
        return hermes_unconfigured(&context);
    };
    let mut path = "/api/hermes/sessions".to_string();
    if !query.is_empty() {
        let params = query
            .iter()
            .map(|(key, value)| format!("{}={}", url_escape(key), url_escape(value)))
            .collect::<Vec<_>>()
            .join("&");
        path.push('?');
        path.push_str(&params);
    }
    proxy_json(
        &context,
        reqwest::Method::GET,
        &upstream,
        &path,
        None,
        Some(profile),
    )
    .await
}

pub async fn search_sessions(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Query(query): Query<HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let profile = query
        .get("profile")
        .map(String::as_str)
        .unwrap_or("default");
    if is_acp_profile(profile) || matches!(query.get("source").map(String::as_str), Some("acp")) {
        let user_id = effective_session_store_user_id(&state, &context).await?;
        let q = query
            .get("q")
            .map(String::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .ok_or_else(|| {
                WebError::bad_request_code("hermes_client_bad_request", "缺少 q")
                    .with_context(&context)
                    .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
                    .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
            })?;
        let workspace_id = query
            .get("workspaceId")
            .map(String::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToOwned::to_owned)
            .or_else(|| context.workspace.workspace_id.clone());
        let limit = query
            .get("limit")
            .and_then(|value| value.parse::<u64>().ok())
            .unwrap_or(20)
            .clamp(1, 50);
        if !use_legacy_convex_acp_store(&query) {
            let runs = state
                .control_plane()
                .list_ai_runtime_runs(
                    &user_id,
                    workspace_id.as_deref(),
                    None,
                    None,
                    limit as usize,
                )
                .map_err(|error| {
                    WebError::internal(format!("SQLite ACP session 搜索失败: {error}"))
                        .with_context(&context)
                })?;
            let needle = q.to_lowercase();
            let results = runs
                .iter()
                .filter(|run| {
                    run.title
                        .as_deref()
                        .unwrap_or_default()
                        .to_lowercase()
                        .contains(&needle)
                        || run.payload_json.to_lowercase().contains(&needle)
                })
                .map(|run| {
                    let mut value = ai_runtime_run_to_json(run);
                    value["snippet"] =
                        Value::String(run.title.clone().unwrap_or_else(|| run.session_id.clone()));
                    value["score"] = Value::from(1);
                    value
                })
                .collect::<Vec<_>>();
            return Ok((
                StatusCode::OK,
                stamp_client_headers(),
                Json(json!({
                    "ok": true,
                    "traceId": context.trace.trace_id,
                    "persistence": ACP_RUNTIME_SQLITE_STORE,
                    "results": results
                })),
            ));
        }
        let mut args = serde_json::Map::new();
        args.insert("userId".into(), Value::String(user_id));
        args.insert("q".into(), Value::String(q.to_string()));
        args.insert("limit".into(), Value::from(limit));
        if let Some(workspace_id) = workspace_id.clone() {
            args.insert("workspaceId".into(), Value::String(workspace_id));
        }
        let results = execute_convex_query_by_name(
            state.config(),
            &context,
            "aiSessions:searchRuntimeSessions",
            Value::Object(args),
            workspace_id.as_deref(),
            "acp_runtime_session_search",
        )
        .await?;
        return Ok((
            StatusCode::OK,
            stamp_client_headers(),
            Json(json!({
                "ok": true,
                "traceId": context.trace.trace_id,
                "persistence": "convex_acp_runtime_store",
                "results": results
            })),
        ));
    }

    let Some(upstream) = configured_upstream_for_profile(profile) else {
        return hermes_unconfigured(&context);
    };
    let params = query
        .iter()
        .map(|(key, value)| format!("{}={}", url_escape(key), url_escape(value)))
        .collect::<Vec<_>>()
        .join("&");
    let path = if params.is_empty() {
        "/api/hermes/search/sessions".to_string()
    } else {
        format!("/api/hermes/search/sessions?{params}")
    };
    proxy_json(
        &context,
        reqwest::Method::GET,
        &upstream,
        &path,
        None,
        Some(profile),
    )
    .await
}

async fn list_acp_sessions(
    state: &AppState,
    context: &RequestContext,
    query: &HashMap<String, String>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    if query.get("sourceKind").map(String::as_str).map(str::trim) == Some("local_folder") {
        let root_uri = query
            .get("rootUri")
            .map(String::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .ok_or_else(|| {
                WebError::bad_request_code("local_ai_session_root_required", "缺少本地会话 rootUri")
                    .with_context(context)
            })?;
        crate::routes::ensure_local_workspace_access(context, root_uri)
            .map_err(|error| error.with_context(context))?;
        let limit = query
            .get("limit")
            .and_then(|value| value.parse::<usize>().ok())
            .unwrap_or(50)
            .clamp(1, 100);
        let sessions = list_local_ai_sessions(root_uri, limit)?;
        return Ok((
            StatusCode::OK,
            stamp_client_headers(),
            Json(json!({
                "ok": true,
                "traceId": context.trace.trace_id,
                "persistence": "local_ai_session_jsonl",
                "sessionStorage": "local_private",
                "sessions": sessions
            })),
        ));
    }
    let user_id = effective_session_store_user_id(state, context).await?;
    let workspace_id = query
        .get("workspaceId")
        .map(String::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
        .or_else(|| context.workspace.workspace_id.clone());
    let document_id = query
        .get("documentId")
        .map(String::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned);
    let session_id = query
        .get("sessionId")
        .map(String::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned);
    let limit = query
        .get("limit")
        .and_then(|value| value.parse::<u64>().ok())
        .unwrap_or(50)
        .clamp(1, 100);
    if !use_legacy_convex_acp_store(query) {
        let runs = state
            .control_plane()
            .list_ai_runtime_runs(
                &user_id,
                workspace_id.as_deref(),
                document_id.as_deref(),
                session_id.as_deref(),
                limit as usize,
            )
            .map_err(|error| {
                WebError::internal(format!("SQLite ACP session 列表读取失败: {error}"))
                    .with_context(context)
            })?;
        return Ok((
            StatusCode::OK,
            stamp_client_headers(),
            Json(json!({
                "ok": true,
                "traceId": context.trace.trace_id,
                "persistence": ACP_RUNTIME_SQLITE_STORE,
                "sessions": runs.iter().map(ai_runtime_run_to_json).collect::<Vec<_>>()
            })),
        ));
    }
    let mut args = serde_json::Map::new();
    args.insert("userId".into(), Value::String(user_id));
    if let Some(workspace_id) = workspace_id {
        args.insert("workspaceId".into(), Value::String(workspace_id));
    }
    if let Some(document_id) = document_id {
        args.insert("documentId".into(), Value::String(document_id));
    }
    if let Some(session_id) = session_id {
        args.insert("sessionId".into(), Value::String(session_id));
    }
    args.insert("limit".into(), Value::from(limit));
    let sessions = execute_convex_query_by_name(
        state.config(),
        context,
        "aiSessions:listRuntimeRuns",
        Value::Object(args),
        query
            .get("workspaceId")
            .map(String::as_str)
            .or(context.workspace.workspace_id.as_deref()),
        "acp_runtime_session_list",
    )
    .await?;
    Ok((
        StatusCode::OK,
        stamp_client_headers(),
        Json(json!({
            "ok": true,
            "traceId": context.trace.trace_id,
            "persistence": "convex_acp_runtime_store",
            "sessions": sessions
        })),
    ))
}

pub async fn create_session(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Json(payload): Json<CreateSessionRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let trace_id = payload
        .trace_id
        .filter(|value| !value.trim().is_empty())
        .unwrap_or_else(|| context.trace.trace_id.clone());
    let document_id = payload
        .document_id
        .as_deref()
        .filter(|value| !value.trim().is_empty())
        .unwrap_or("current");
    let session_id = stable_session_id(document_id, &trace_id);
    let profile = payload
        .profile
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("default");
    let mut persistence = "hermes_on_first_run";
    let session_store_user_id = effective_session_store_user_id(&state, &context).await?;
    if payload.source_kind.as_deref().map(str::trim) == Some("local_folder") {
        let root_uri = payload
            .root_uri
            .as_deref()
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .ok_or_else(|| {
                WebError::bad_request_code("local_ai_session_root_required", "缺少本地会话 rootUri")
                    .with_context(&context)
                    .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
                    .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
            })?;
        let share_id = payload
            .share_id
            .as_deref()
            .map(str::trim)
            .filter(|value| !value.is_empty());
        let share_grant = if let Some(share_id) = share_id {
            let grant = load_local_share_grant_for_actor(
                share_id,
                Some(&context.auth.actor_id),
                Some(root_uri),
            )
            .map_err(|error| error.with_context(&context))?;
            Some(grant)
        } else {
            crate::routes::ensure_local_workspace_access(&context, root_uri)
                .map_err(|error| error.with_context(&context))?;
            None
        };
        let permission_level = share_grant
            .as_ref()
            .map(LocalShareGrant::permission_level)
            .or_else(|| {
                payload
                    .permission_level
                    .as_deref()
                    .map(str::trim)
                    .filter(|value| !value.is_empty())
            })
            .unwrap_or(if share_id.is_some() {
                "shared_read"
            } else {
                "private"
            });
        if share_id.is_some() && permission_level == "shared_read" {
            return Err(WebError::new(
                StatusCode::FORBIDDEN,
                "local_ai_session_shared_read_write_forbidden",
                "共享只读 AI 会话不能创建可写本地会话记录",
            )
            .with_context(&context)
            .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
            .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client"));
        }
        let shared_write = share_id.is_some() && permission_level == "shared_write";
        let visibility = if shared_write { "shared" } else { "private" };
        let session_storage = if shared_write {
            "local_shared"
        } else {
            "local_private"
        };
        let event = json!({
            "schema": "mnote.local_ai_session_event.v1",
            "eventType": "session.created",
            "sessionId": session_id,
            "workspaceId": payload.workspace_id,
            "documentId": payload.document_id,
            "sourceKind": "local_folder",
            "visibility": visibility,
            "permissionLevel": permission_level,
            "shareId": share_id,
            "shareContext": share_grant.as_ref().map(LocalShareGrant::share_context).unwrap_or(Value::Null),
            "allowedResourceIds": share_grant.as_ref().map(|grant| grant.allowed_resource_ids.clone()).unwrap_or_else(|| vec![document_id.to_string()]),
            "title": payload.title.as_deref().unwrap_or("当前页问答"),
            "profile": profile,
            "userId": session_store_user_id,
            "traceId": trace_id,
            "createdAt": now_ms()
        });
        append_local_ai_session_event(root_uri, &session_id, &event, share_id)
            .map_err(|error| error.with_context(&context))?;
        if shared_write {
            let audit_event = json!({
                "schema": "mnote.local_ai_session_event.v1",
                "eventType": "audit.shared_write",
                "sessionId": session_id,
                "workspaceId": payload.workspace_id,
                "documentId": payload.document_id,
                "sourceKind": "local_folder",
                "permissionLevel": permission_level,
                "shareId": share_id,
                "shareContext": share_grant.as_ref().map(LocalShareGrant::share_context).unwrap_or(Value::Null),
                "allowedResourceIds": share_grant.as_ref().map(|grant| grant.allowed_resource_ids.clone()).unwrap_or_default(),
                "actorId": context.auth.actor_id,
                "userId": session_store_user_id,
                "traceId": trace_id,
                "changedFiles": [],
                "diffSummary": "shared AI session created",
                "createdAt": now_ms()
            });
            append_local_ai_session_event(root_uri, &session_id, &audit_event, share_id)
                .map_err(|error| error.with_context(&context))?;
        }
        return Ok((
            StatusCode::OK,
            stamp_client_headers(),
            Json(json!({
                "ok": true,
                "sessionId": session_id,
                "workspaceId": payload.workspace_id,
                "documentId": payload.document_id,
                "profile": profile,
                "title": payload.title.unwrap_or_else(|| "当前页问答".into()),
                "traceId": trace_id,
                "persistence": "local_ai_session_jsonl",
                "sessionStorage": session_storage,
                "permissionLevel": permission_level,
                "shareId": share_id
            })),
        ));
    }
    let runtime_payload = json!({
        "workspaceId": payload.workspace_id.clone(),
        "documentId": payload.document_id.clone(),
        "sessionId": session_id.clone(),
        "traceId": trace_id.clone(),
        "profile": profile,
        "title": payload.title.clone(),
        "actorId": session_store_user_id,
        "actorType": "user",
    });
    if let Some(acp_runtime) = acp_runtime_for_payload(&runtime_payload, profile) {
        let registration = HermesRunRegistration {
            session_id: session_id.clone(),
            profile: profile.to_string(),
            document_id: document_id.to_string(),
            trace_id: trace_id.clone(),
        };
        let session_index_run_id = format!("{}_session", session_id);
        let runtime_state = json!({
            "sessionId": session_id.clone(),
            "runId": session_index_run_id.clone(),
            "profile": profile,
            "documentId": document_id,
            "traceId": trace_id.clone(),
            "status": "session.created"
        });
        persist_acp_runtime_run(
            &state,
            &context,
            &registration,
            &session_index_run_id,
            &acp_runtime,
            &runtime_state,
            &runtime_payload,
        )
        .await?;
        persistence = ACP_RUNTIME_SQLITE_STORE;
    }
    Ok((
        StatusCode::OK,
        stamp_client_headers(),
        Json(json!({
            "ok": true,
            "sessionId": session_id,
            "workspaceId": payload.workspace_id,
            "documentId": payload.document_id,
            "profile": profile,
            "title": payload.title.unwrap_or_else(|| "当前页问答".into()),
            "traceId": trace_id,
            "persistence": persistence
        })),
    ))
}

pub async fn list_profiles(
    Extension(context): Extension<RequestContext>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    Ok((
        StatusCode::OK,
        stamp_client_headers(),
        Json(list_profiles_payload()),
    ))
}

pub async fn gateway_health(
    Extension(context): Extension<RequestContext>,
    Query(query): Query<HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let profile = query
        .get("profile")
        .map(String::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
        .or_else(active_profile_name)
        .unwrap_or_else(|| "default".into());
    let profile_status = profile_gateway_status(&profile);
    if is_acp_profile(&profile) {
        let runtime_name = crate::acp_bridge::runtime_name_for_profile(&profile);
        return Ok((
            StatusCode::OK,
            stamp_client_headers(),
            Json(json!({
                "ok": true,
                "traceId": context.trace.trace_id,
                "profile": profile_status,
                "gateway": {
                    "configured": true,
                    "upstream": Value::Null,
                    "ok": true,
                    "status": "acp",
                    "runtime": runtime_name,
                    "transport": "acp",
                    "message": "页面 AI 默认使用 ACP runtime，Hermes HTTP gateway 已退役。"
                },
                "suggestions": []
            })),
        ));
    }
    let upstream = configured_upstream_for_profile(&profile);
    let mut suggestions = profile_status
        .get("suggestions")
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default();
    if upstream.is_none() {
        suggestions.push(Value::String(
            "未配置 MNOTE_WEB_HERMES_UPSTREAM_URL；请先启动 Hermes gateway，并让 mnote-web 指向该 gateway。"
                .into(),
        ));
    }

    let mut gateway = json!({
        "configured": upstream.is_some(),
        "upstream": upstream,
        "ok": false,
        "status": if upstream.is_some() { "checking" } else { "unconfigured" }
    });
    if let Some(upstream_url) = gateway["upstream"].as_str().map(ToOwned::to_owned) {
        let probe =
            probe_gateway_health(&upstream_url, configured_api_key_for_profile(&profile)).await;
        gateway["ok"] = Value::Bool(probe.ok);
        gateway["status"] = Value::String(probe.status);
        gateway["httpStatus"] = probe.http_status.map(Value::from).unwrap_or(Value::Null);
        gateway["path"] = probe.path.map(Value::from).unwrap_or(Value::Null);
        gateway["message"] = probe.message.map(Value::from).unwrap_or(Value::Null);
        if !probe.ok {
            suggestions.extend(
                hermes_settings_suggestions(
                    gateway["httpStatus"].as_u64(),
                    gateway["message"].as_str(),
                )
                .into_iter()
                .map(Value::String),
            );
        }
    }
    let ok = gateway["ok"].as_bool().unwrap_or(false)
        && profile_status["modelConfigured"].as_bool().unwrap_or(false)
        && profile_status["apiKeyConfigured"]
            .as_bool()
            .unwrap_or(false);
    Ok((
        StatusCode::OK,
        stamp_client_headers(),
        Json(json!({
            "ok": ok,
            "traceId": context.trace.trace_id,
            "profile": profile_status,
            "gateway": gateway,
            "suggestions": unique_string_values(suggestions)
        })),
    ))
}

pub async fn get_profile(
    Extension(context): Extension<RequestContext>,
    Path(profile_name): Path<String>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    Ok((
        StatusCode::OK,
        stamp_client_headers(),
        Json(json!({
            "ok": true,
            "profile": profile_detail_payload(&profile_name)
        })),
    ))
}

pub async fn switch_active_profile(
    Extension(context): Extension<RequestContext>,
    Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let name = payload
        .get("name")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| {
            WebError::bad_request_code("hermes_client_bad_request", "缺少 profile name")
                .with_context(&context)
                .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
                .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
        })?;
    switch_active_profile_local(name).map_err(|error| {
        WebError::bad_gateway_code(
            "hermes_client_profile_switch_failed",
            format!("切换 Hermes profile 失败: {error}"),
        )
        .with_context(&context)
        .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
    })?;
    Ok((
        StatusCode::OK,
        stamp_client_headers(),
        Json(json!({
            "ok": true,
            "profile": profile_detail_payload(name)
        })),
    ))
}

pub async fn get_profile_memory(
    Extension(context): Extension<RequestContext>,
    Query(query): Query<HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into());
    let profile = query
        .get("profile")
        .map(String::as_str)
        .unwrap_or(fallback_profile.as_str());
    Ok((
        StatusCode::OK,
        stamp_client_headers(),
        Json(profile_memory_payload(profile)),
    ))
}

pub async fn save_profile_memory(
    Extension(context): Extension<RequestContext>,
    Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into());
    let profile = payload
        .get("profile")
        .and_then(Value::as_str)
        .unwrap_or(fallback_profile.as_str());
    let section = payload
        .get("section")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| matches!(*value, "memory" | "user" | "soul"))
        .ok_or_else(|| {
            WebError::bad_request_code(
                "hermes_client_bad_request",
                "section 必须是 memory、user 或 soul",
            )
            .with_context(&context)
            .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
            .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
        })?;
    let content = payload
        .get("content")
        .and_then(Value::as_str)
        .unwrap_or_default();
    save_profile_memory_local(profile, section, content).map_err(|error| {
        WebError::bad_gateway_code(
            "hermes_client_memory_save_failed",
            format!("保存 Hermes profile memory 失败: {error}"),
        )
        .with_context(&context)
        .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
    })?;
    Ok((
        StatusCode::OK,
        stamp_client_headers(),
        Json(json!({"ok": true})),
    ))
}

pub async fn list_skills(
    Extension(context): Extension<RequestContext>,
    Query(query): Query<HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into());
    let profile = query
        .get("profile")
        .map(String::as_str)
        .unwrap_or(fallback_profile.as_str());
    let runtime = query.get("runtime").map(String::as_str).unwrap_or(profile);
    Ok((
        StatusCode::OK,
        stamp_client_headers(),
        Json(if runtime == "reasonix" {
            reasonix_skills_payload()
        } else {
            skills_payload(profile)
        }),
    ))
}

pub async fn toggle_skill(
    Extension(context): Extension<RequestContext>,
    Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let name = payload
        .get("name")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| {
            WebError::bad_request_code("hermes_client_bad_request", "缺少 skill name")
                .with_context(&context)
                .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
                .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
        })?;
    let enabled = payload
        .get("enabled")
        .and_then(Value::as_bool)
        .ok_or_else(|| {
            WebError::bad_request_code("hermes_client_bad_request", "缺少 enabled")
                .with_context(&context)
                .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
                .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
        })?;
    let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into());
    let profile = payload
        .get("profile")
        .and_then(Value::as_str)
        .unwrap_or(fallback_profile.as_str());
    set_skill_enabled(profile, name, enabled).map_err(|error| {
        WebError::bad_gateway_code(
            "hermes_client_skill_toggle_failed",
            format!("更新 Hermes skill 设置失败: {error}"),
        )
        .with_context(&context)
        .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
    })?;
    Ok((
        StatusCode::OK,
        stamp_client_headers(),
        Json(json!({"ok": true})),
    ))
}

pub async fn toggle_tool(
    Extension(context): Extension<RequestContext>,
    Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let name = payload
        .get("name")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| {
            WebError::bad_request_code("hermes_client_bad_request", "缺少 tool name")
                .with_context(&context)
                .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
                .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
        })?;
    let enabled = payload
        .get("enabled")
        .and_then(Value::as_bool)
        .ok_or_else(|| {
            WebError::bad_request_code("hermes_client_bad_request", "缺少 enabled")
                .with_context(&context)
                .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
                .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
        })?;
    let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into());
    let profile = payload
        .get("profile")
        .and_then(Value::as_str)
        .unwrap_or(fallback_profile.as_str());
    set_mnote_tool_enabled(profile, name, enabled).map_err(|error| {
        WebError::bad_gateway_code(
            "hermes_client_tool_toggle_failed",
            format!("更新 mnote tool 设置失败: {error}"),
        )
        .with_context(&context)
        .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
    })?;
    Ok((
        StatusCode::OK,
        stamp_client_headers(),
        Json(json!({"ok": true})),
    ))
}

pub async fn get_session(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Path(session_id): Path<String>,
    Query(query): Query<HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    if is_acp_session_query(&query) {
        return get_acp_session(&state, &context, &session_id, &query).await;
    }
    let runtime = runtime_state_for_session(&session_id);
    if let Some(session) = load_session_from_hermes_cli(&session_id).await {
        return Ok((
            StatusCode::OK,
            stamp_client_headers(),
            Json(json!({
                "ok": true,
                "traceId": context.trace.trace_id,
                "sessionId": session_id,
                "session": session,
                "runtime": runtime
            })),
        ));
    }
    Ok((
        StatusCode::OK,
        stamp_client_headers(),
        Json(json!({
            "ok": true,
            "traceId": context.trace.trace_id,
            "sessionId": session_id,
            "session": {
                "sessionId": session_id,
                "messages": []
            },
            "runtime": runtime
        })),
    ))
}

pub async fn resume_session(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Path(session_id): Path<String>,
    Query(query): Query<HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    if is_acp_session_query(&query) {
        let result = get_acp_session(&state, &context, &session_id, &query).await?;
        let mut payload = result.2 .0;
        payload["resumed"] = Value::Bool(true);
        payload["resumeSource"] = payload
            .get("persistence")
            .cloned()
            .unwrap_or_else(|| Value::String(ACP_RUNTIME_SQLITE_STORE.into()));
        return Ok((result.0, result.1, Json(payload)));
    }
    get_session(
        State(state),
        Extension(context),
        Path(session_id),
        Query(query),
    )
    .await
}

pub async fn delete_session(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Path(session_id): Path<String>,
    Query(query): Query<HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    if !use_legacy_convex_acp_store(&query) {
        let user_id = effective_session_store_user_id(&state, &context).await?;
        let workspace_id = query
            .get("workspaceId")
            .map(String::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToOwned::to_owned)
            .or_else(|| context.workspace.workspace_id.clone());
        let deleted = state
            .control_plane()
            .delete_ai_runtime_session(&user_id, &session_id, workspace_id.as_deref())
            .map_err(|error| {
                WebError::internal(format!("SQLite ACP session 删除失败: {error}"))
                    .with_context(&context)
            })?;
        return Ok((
            StatusCode::OK,
            stamp_client_headers(),
            Json(json!({
                "ok": true,
                "persistence": ACP_RUNTIME_SQLITE_STORE,
                "result": {
                    "ok": true,
                    "sessionId": session_id,
                    "deleted": deleted
                }
            })),
        ));
    }
    let result = execute_acp_session_mutation(
        &state,
        &context,
        &session_id,
        &query,
        "aiSessions:deleteRuntimeSession",
        None,
        "acp_runtime_session_delete",
    )
    .await?;
    Ok((
        StatusCode::OK,
        stamp_client_headers(),
        Json(json!({
            "ok": true,
            "persistence": "convex_acp_runtime_store",
            "result": result
        })),
    ))
}

pub async fn rename_session(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Path(session_id): Path<String>,
    Query(query): Query<HashMap<String, String>>,
    Json(payload): Json<RenameSessionRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let title = payload.title.trim();
    if title.is_empty() {
        return Err(
            WebError::bad_request_code("hermes_client_bad_request", "缺少 title")
                .with_context(&context)
                .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
                .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client"),
        );
    }
    if !use_legacy_convex_acp_store(&query) {
        let user_id = effective_session_store_user_id(&state, &context).await?;
        let workspace_id = query
            .get("workspaceId")
            .map(String::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToOwned::to_owned)
            .or_else(|| context.workspace.workspace_id.clone());
        let runs = state
            .control_plane()
            .rename_ai_runtime_session(&user_id, &session_id, workspace_id.as_deref(), title)
            .map_err(|error| {
                WebError::internal(format!("SQLite ACP session 重命名失败: {error}"))
                    .with_context(&context)
            })?;
        return Ok((
            StatusCode::OK,
            stamp_client_headers(),
            Json(json!({
                "ok": true,
                "persistence": ACP_RUNTIME_SQLITE_STORE,
                "result": {
                    "ok": true,
                    "sessionId": session_id,
                    "title": title,
                    "runs": runs.iter().map(ai_runtime_run_to_json).collect::<Vec<_>>()
                }
            })),
        ));
    }
    let result = execute_acp_session_mutation(
        &state,
        &context,
        &session_id,
        &query,
        "aiSessions:renameRuntimeSession",
        Some(json!({"title": title})),
        "acp_runtime_session_rename",
    )
    .await?;
    Ok((
        StatusCode::OK,
        stamp_client_headers(),
        Json(json!({
            "ok": true,
            "persistence": "convex_acp_runtime_store",
            "result": result
        })),
    ))
}

pub async fn auto_title_session(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Path(session_id): Path<String>,
    Query(query): Query<HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    if !use_legacy_convex_acp_store(&query) {
        let user_id = effective_session_store_user_id(&state, &context).await?;
        let workspace_id = query
            .get("workspaceId")
            .map(String::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToOwned::to_owned)
            .or_else(|| context.workspace.workspace_id.clone());
        let titled = state
            .control_plane()
            .auto_title_ai_runtime_session(&user_id, &session_id, workspace_id.as_deref())
            .map_err(|error| {
                WebError::internal(format!("SQLite ACP session 自动标题失败: {error}"))
                    .with_context(&context)
            })?;
        let title = titled.as_ref().and_then(|run| run.title.clone());
        return Ok((
            StatusCode::OK,
            stamp_client_headers(),
            Json(json!({
                "ok": true,
                "persistence": ACP_RUNTIME_SQLITE_STORE,
                "result": {
                    "ok": true,
                    "sessionId": session_id,
                    "title": title,
                    "run": titled.as_ref().map(ai_runtime_run_to_json)
                }
            })),
        ));
    }
    let result = execute_acp_session_mutation(
        &state,
        &context,
        &session_id,
        &query,
        "aiSessions:autoTitleRuntimeSession",
        None,
        "acp_runtime_session_auto_title",
    )
    .await?;
    Ok((
        StatusCode::OK,
        stamp_client_headers(),
        Json(json!({
            "ok": true,
            "persistence": "convex_acp_runtime_store",
            "result": result
        })),
    ))
}

async fn execute_acp_session_mutation(
    state: &AppState,
    context: &RequestContext,
    session_id: &str,
    query: &HashMap<String, String>,
    function_name: &str,
    extra: Option<Value>,
    error_phase: &'static str,
) -> Result<Value, WebError> {
    let user_id = effective_session_store_user_id(state, context).await?;
    let workspace_id = query
        .get("workspaceId")
        .map(String::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
        .or_else(|| context.workspace.workspace_id.clone());
    let mut args = serde_json::Map::new();
    args.insert("userId".into(), Value::String(user_id));
    args.insert("sessionId".into(), Value::String(session_id.to_string()));
    if let Some(workspace_id) = workspace_id.clone() {
        args.insert("workspaceId".into(), Value::String(workspace_id));
    }
    if let Some(Value::Object(extra)) = extra {
        args.extend(extra);
    }
    execute_convex_mutation_by_name(
        state.config(),
        context,
        function_name,
        Value::Object(args),
        workspace_id.as_deref(),
        Some(session_id),
        error_phase,
    )
    .await
}

fn is_acp_session_query(query: &HashMap<String, String>) -> bool {
    matches!(query.get("source").map(String::as_str), Some("acp"))
        || query
            .get("profile")
            .map(String::as_str)
            .map(is_acp_profile)
            .unwrap_or(false)
}

async fn get_acp_session(
    state: &AppState,
    context: &RequestContext,
    session_id: &str,
    query: &HashMap<String, String>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    if query.get("sourceKind").map(String::as_str).map(str::trim) == Some("local_folder") {
        let root_uri = query
            .get("rootUri")
            .map(String::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .ok_or_else(|| {
                WebError::bad_request_code("local_ai_session_root_required", "缺少本地会话 rootUri")
                    .with_context(context)
            })?;
        crate::routes::ensure_local_workspace_access(context, root_uri)
            .map_err(|error| error.with_context(context))?;
        let events = read_local_ai_session_events(root_uri, session_id, None)?;
        return Ok((
            StatusCode::OK,
            stamp_client_headers(),
            Json(json!({
                "ok": true,
                "traceId": context.trace.trace_id,
                "persistence": "local_ai_session_jsonl",
                "sessionStorage": "local_private",
                "sessionId": session_id,
                "session": {
                    "sessionId": session_id,
                    "messages": [],
                    "events": events
                },
                "runtime": runtime_state_for_session(session_id),
                "events": events
            })),
        ));
    }
    let user_id = effective_session_store_user_id(state, context).await?;
    let workspace_id = query
        .get("workspaceId")
        .map(String::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
        .or_else(|| context.workspace.workspace_id.clone());
    if !use_legacy_convex_acp_store(query) {
        let runs = state
            .control_plane()
            .list_ai_runtime_runs(
                &user_id,
                workspace_id.as_deref(),
                None,
                Some(session_id),
                20,
            )
            .map_err(|error| {
                WebError::internal(format!("SQLite ACP session 详情读取失败: {error}"))
                    .with_context(context)
            })?;
        let latest_run = runs.first().cloned();
        let events = if let Some(run) = latest_run.as_ref() {
            state
                .control_plane()
                .list_ai_runtime_events(&user_id, &run.run_id, 200)
                .map_err(|error| {
                    WebError::internal(format!("SQLite ACP runtime events 读取失败: {error}"))
                        .with_context(context)
                })?
        } else {
            Vec::new()
        };
        let runtime = latest_run
            .as_ref()
            .and_then(|run| serde_json::from_str::<Value>(&run.runtime_json).ok())
            .unwrap_or_else(|| runtime_state_for_session(session_id));
        return Ok((
            StatusCode::OK,
            stamp_client_headers(),
            Json(json!({
                "ok": true,
                "traceId": context.trace.trace_id,
                "persistence": ACP_RUNTIME_SQLITE_STORE,
                "sessionId": session_id,
                "session": {
                    "sessionId": session_id,
                    "messages": [],
                    "runs": runs.iter().map(ai_runtime_run_to_json).collect::<Vec<_>>()
                },
                "runtime": runtime,
                "events": events.iter().map(ai_runtime_event_to_json).collect::<Vec<_>>()
            })),
        ));
    }
    let mut run_args = serde_json::Map::new();
    run_args.insert("userId".into(), Value::String(user_id.clone()));
    run_args.insert("sessionId".into(), Value::String(session_id.to_string()));
    if let Some(workspace_id) = workspace_id.clone() {
        run_args.insert("workspaceId".into(), Value::String(workspace_id));
    }
    run_args.insert("limit".into(), Value::from(20u64));
    let runs = execute_convex_query_by_name(
        state.config(),
        context,
        "aiSessions:listRuntimeRuns",
        Value::Object(run_args),
        workspace_id.as_deref(),
        "acp_runtime_session_detail_runs",
    )
    .await?;
    let latest_run = runs
        .as_array()
        .and_then(|rows| rows.first())
        .cloned()
        .unwrap_or(Value::Null);
    let events = if let Some(run_id) = latest_run
        .get("runId")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
    {
        execute_convex_query_by_name(
            state.config(),
            context,
            "aiSessions:listRuntimeEvents",
            json!({
                "userId": user_id,
                "runId": run_id,
                "limit": 200
            }),
            workspace_id.as_deref(),
            "acp_runtime_session_detail_events",
        )
        .await?
    } else {
        Value::Array(Vec::new())
    };
    let runtime = latest_run
        .get("runtime")
        .cloned()
        .unwrap_or_else(|| runtime_state_for_session(session_id));
    Ok((
        StatusCode::OK,
        stamp_client_headers(),
        Json(json!({
            "ok": true,
            "traceId": context.trace.trace_id,
            "persistence": "convex_acp_runtime_store",
            "sessionId": session_id,
            "session": {
                "sessionId": session_id,
                "messages": [],
                "runs": runs
            },
            "runtime": runtime,
            "events": events
        })),
    ))
}

async fn load_session_from_hermes_cli(session_id: &str) -> Option<Value> {
    let session_id = session_id.to_string();
    tokio::task::spawn_blocking(move || {
        let hermes_bin = std::env::var("MNOTE_WEB_HERMES_BIN")
            .ok()
            .filter(|value| !value.trim().is_empty())
            .unwrap_or_else(|| "hermes".into());
        let output = Command::new(hermes_bin)
            .args(["sessions", "export", "--session-id", &session_id, "-"])
            .output()
            .ok()?;
        if !output.status.success() {
            return None;
        }
        let stdout = String::from_utf8(output.stdout).ok()?;
        stdout
            .lines()
            .find_map(|line| serde_json::from_str::<Value>(line).ok())
    })
    .await
    .ok()
    .flatten()
}

pub async fn create_run(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Json(mut payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let (actor_id, actor_type) = resolve_run_actor(&state, &context).await;
    stamp_run_actor(&mut payload, &actor_id, &actor_type);
    let registration = run_registration_from_payload(&context, &payload);

    // ACP path: skip the HTTP proxy, just register and return run info
    if let Some(acp_runtime) = acp_runtime_for_payload(&payload, &registration.profile) {
        let run_id = payload
            .get("runId")
            .and_then(Value::as_str)
            .filter(|value| !value.trim().is_empty())
            .map(ToOwned::to_owned)
            .unwrap_or_else(|| new_acp_run_id(&registration));
        payload["runId"] = Value::String(run_id.clone());
        let runtime_state = register_acp_runtime(&registration, &run_id);
        // Store payload for stream_events to use
        ACP_RUN_PAYLOADS
            .lock()
            .expect("acp run payloads")
            .insert(run_id.clone(), payload.clone());
        let persistence_result = persist_acp_runtime_run(
            &state,
            &context,
            &registration,
            &run_id,
            &acp_runtime,
            &runtime_state,
            &payload,
        )
        .await?;
        if payload
            .get("sourceKind")
            .and_then(Value::as_str)
            .map(str::trim)
            == Some("local_folder")
        {
            if let Some(root_uri) = payload.get("rootUri").and_then(Value::as_str) {
                match local_agent_audit_collect_snapshot(root_uri) {
                    Ok(snapshot) => local_agent_audit_store_snapshot(&run_id, snapshot),
                    Err(error) => {
                        warn!(error = ?error, run_id = %run_id, "本地 agent audit 初始快照采集失败");
                    }
                }
            }
        }
        let response = json!({
            "ok": true,
            "runId": run_id,
            "sessionId": registration.session_id,
            "profile": registration.profile,
            "traceId": context.trace.trace_id,
            "runtime": runtime_state,
            "persistence": persistence_result
                .get("persistence")
                .and_then(Value::as_str)
                .unwrap_or(ACP_RUNTIME_SQLITE_STORE),
            "sessionStorage": persistence_result
                .get("sessionStorage")
                .cloned()
                .unwrap_or(Value::Null),
        });
        return Ok((StatusCode::OK, stamp_client_headers(), Json(response)));
    }

    if session_has_active_run(&registration.session_id) {
        let queued = enqueue_run(&context, &registration, &payload)?;
        return Ok((StatusCode::ACCEPTED, stamp_client_headers(), Json(queued)));
    }
    let Some(upstream) = configured_upstream_for_profile(&registration.profile) else {
        return hermes_unconfigured(&context);
    };
    let upstream_body = build_run_upstream_body(&context, payload)?;
    let result = proxy_json(
        &context,
        reqwest::Method::POST,
        &upstream,
        "/v1/runs",
        Some(upstream_body),
        Some(&registration.profile),
    )
    .await?;
    if let Some(runtime) = register_runtime_from_create_run_response(&registration, &result.2 .0) {
        let mut payload = result.2 .0;
        payload["runtime"] = runtime;
        Ok((result.0, result.1, Json(payload)))
    } else {
        Ok(result)
    }
}

pub async fn cancel_queued_run(
    Extension(context): Extension<RequestContext>,
    Path((session_id, queue_id)): Path<(String, String)>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let cancelled = remove_queued_run(&session_id, &queue_id);
    Ok((
        StatusCode::OK,
        stamp_client_headers(),
        Json(json!({
            "ok": true,
            "sessionId": session_id,
            "queueId": queue_id,
            "cancelled": cancelled,
            "queueLength": queue_len_for_session(&session_id)
        })),
    ))
}

/// ACP variant of stream_events: creates an ACP session, runs the prompt,
/// and returns an SSE stream of events.
async fn acp_stream_events(
    state: AppState,
    context: RequestContext,
    run_id: &str,
    profile: &str,
    acp_runtime_name: &str,
) -> Result<Response, WebError> {
    // Get the stored payload from create_run
    let payload = acp_run_payload_for_stream(run_id).ok_or_else(|| {
        WebError::bad_gateway_code(
            "acp_run_payload_not_found",
            format!("ACP run payload not found for run_id={run_id}"),
        )
        .with_context(&context)
    })?;

    // Build prompt from the message field (frontend sends "message", not "input")
    let input = payload
        .get("message")
        .or_else(|| payload.get("input"))
        .and_then(Value::as_str)
        .unwrap_or("请读取当前文档内容");

    let mut prompt_blocks = Vec::new();
    if let Ok(upstream_body) = build_run_upstream_body(&context, payload.clone()) {
        if let Some(instructions) = upstream_body.get("instructions").and_then(Value::as_str) {
            prompt_blocks.push(ContentBlock::Text {
                text: format!(
                    "以下是 mnote 页面 AI 的冻结上下文与工具约束，请在本轮回答中遵守：\n{instructions}"
                ),
            });
        }
    }
    prompt_blocks.push(ContentBlock::Text {
        text: input.to_string(),
    });

    let runtime_name = acp_runtime_name;

    // Ensure runtime is active; switch_to either activates it or returns existing
    let access_env = acp_allowed_roots_env_for_payload(&payload);
    let client = if runtime_name == "hermes" {
        let hermes_bin = std::env::var("MNOTE_WEB_HERMES_BIN")
            .ok()
            .filter(|value| !value.trim().is_empty())
            .unwrap_or_else(|| "hermes".into());
        let mut config =
            crate::acp_runtime::AcpRuntimeConfig::hermes(Some(&hermes_bin), Some(profile));
        config.env = merge_acp_runtime_env(acp_hermes_env_for_profile(profile), access_env);
        state.acp_runtime.switch_to_config(config).await
    } else {
        match state.acp_runtime.get_config(runtime_name).cloned() {
            Some(mut config) => {
                config.env = merge_acp_runtime_env(config.env, access_env);
                state.acp_runtime.switch_to_config(config).await
            }
            None => Err(crate::acp_client::AcpError::Internal(format!(
                "unknown runtime: {runtime_name}"
            ))),
        }
    }
    .map_err(|e| {
        WebError::bad_gateway_code(
            "acp_runtime_switch_failed",
            format!("Failed to activate ACP runtime '{runtime_name}': {e}"),
        )
        .with_context(&context)
    })?;

    // Create session manager and start the prompt
    let mgr = Arc::new(crate::acp_session_manager::AcpSessionManager::new(client));
    let (event_tx, _event_rx) = broadcast::channel(256);
    let event_tx_clone = event_tx.clone();

    mgr.on_event(move |event| {
        if let Some(sse) = crate::acp_bridge::acp_event_to_sse(event) {
            let _ = event_tx_clone.send(sse);
        }
    });

    // Try to resume stored ACP session, or create a new one.
    // Reference: hermes-vscode-main sessionManager.ts ensureSession()
    let stored_acp_session_id = payload
        .get("acpSessionId")
        .and_then(Value::as_str)
        .filter(|s| !s.trim().is_empty());
    let acp_session_id = mgr
        .ensure_session(None, stored_acp_session_id)
        .await
        .map_err(|e| {
            WebError::bad_gateway_code(
                "acp_session_ensure_failed",
                format!("ACP session ensure failed: {e}"),
            )
            .with_context(&context)
        })?;
    let mnote_session_id = session_id_for_run(run_id).unwrap_or_else(|| run_id.to_string());
    ACP_ACTIVE_RUNS.lock().expect("acp active runs").insert(
        run_id.to_string(),
        AcpActiveRun {
            manager: Arc::clone(&mgr),
            mnote_session_id: mnote_session_id.clone(),
            acp_session_id,
            event_tx: event_tx.clone(),
        },
    );

    // Build SSE response from event channel FIRST (before running prompt),
    // so that if the prompt fails quickly, events are not lost.
    use tokio::sync::mpsc;
    let (tx, rx) = mpsc::channel::<Result<axum::body::Bytes, std::convert::Infallible>>(256);
    let mut event_rx = event_tx.subscribe();
    let state_for_events = state.clone();
    let context_for_events = context.clone();
    let event_registration = run_registration_from_payload(&context, &payload);
    let event_payload = payload.clone();
    let run_id_for_events = run_id.to_string();
    let acp_runtime_for_events = acp_runtime_name.to_string();

    tokio::spawn(async move {
        loop {
            match event_rx.recv().await {
                Ok(event) => {
                    if let Err(error) = persist_acp_runtime_event(
                        &state_for_events,
                        &context_for_events,
                        &event_registration,
                        &run_id_for_events,
                        &acp_runtime_for_events,
                        &event.event,
                        &event.data,
                        &event_payload,
                    )
                    .await
                    {
                        warn!(error = ?error, run_id = %run_id_for_events, event = %event.event, "ACP runtime event 持久化失败");
                    }
                    let json_str =
                        serde_json::to_string(&event.data).unwrap_or_else(|_| "{}".into());
                    let bytes = axum::body::Bytes::from(format!(
                        "event: {}\ndata: {}\n\n",
                        event.event, json_str
                    ));
                    if tx.send(Ok(bytes)).await.is_err() {
                        break;
                    }
                }
                Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
                    warn!("ACP SSE lagged: {n} events dropped");
                }
                Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
            }
        }
    });

    // Run prompt in background
    let run_id_owned = run_id.to_string();
    let mgr_clone = Arc::clone(&mgr);
    let event_tx_prompt = event_tx.clone();
    let audit_context = context.clone();
    let audit_payload = payload.clone();
    let audit_runtime = acp_runtime_name.to_string();
    let mnote_tool_context = crate::acp_session_manager::AcpMnoteToolContext {
        mnote_session_id: Some(mnote_session_id.clone()),
        run_id: Some(run_id.to_string()),
        actor_id: payload
            .get("actorId")
            .and_then(Value::as_str)
            .map(ToOwned::to_owned),
        trace_id: Some(context.trace.trace_id.clone()),
        workspace_id: payload
            .get("workspaceId")
            .and_then(Value::as_str)
            .map(ToOwned::to_owned),
        document_id: payload
            .get("documentId")
            .and_then(Value::as_str)
            .map(ToOwned::to_owned),
    };
    update_runtime_by_run_id(&run_id_owned, "running", Some("acp.prompt.started"), None);
    tokio::spawn(async move {
        match mgr_clone
            .run_prompt_with_mnote_context(prompt_blocks, Some(mnote_tool_context))
            .await
        {
            Ok(result) => {
                info!("ACP prompt completed: stop_reason={:?}", result.stop_reason);
                let prompt_cancelled =
                    matches!(result.stop_reason, crate::acp_types::StopReason::Cancelled);
                let runtime_aborted = matches!(
                    runtime_status_for_run(&run_id_owned).as_deref(),
                    Some("aborting" | "aborted")
                );
                let agent_audit = if audit_payload
                    .get("sourceKind")
                    .and_then(Value::as_str)
                    .map(str::trim)
                    == Some("local_folder")
                {
                    match local_agent_audit_finalize_run(
                        &audit_context,
                        &audit_payload,
                        &run_id_owned,
                        &audit_runtime,
                        "completed",
                    ) {
                        Ok(event) => event,
                        Err(error) => {
                            warn!(error = ?error, run_id = %run_id_owned, "本地 agent audit 完成事件写入失败");
                            Value::Null
                        }
                    }
                } else {
                    Value::Null
                };
                let _ = event_tx_prompt.send(crate::acp_bridge::SseEvent {
                    event: if prompt_cancelled || runtime_aborted {
                        "run.aborted".into()
                    } else {
                        "run.completed".into()
                    },
                    data: json!({
                        "stopReason": format!("{:?}", result.stop_reason),
                        "agentAudit": agent_audit,
                    }),
                });
            }
            Err(e) => {
                warn!("ACP prompt failed: {e}");
                let agent_audit = if audit_payload
                    .get("sourceKind")
                    .and_then(Value::as_str)
                    .map(str::trim)
                    == Some("local_folder")
                {
                    match local_agent_audit_finalize_run(
                        &audit_context,
                        &audit_payload,
                        &run_id_owned,
                        &audit_runtime,
                        "failed",
                    ) {
                        Ok(event) => event,
                        Err(error) => {
                            warn!(error = ?error, run_id = %run_id_owned, "本地 agent audit 失败事件写入失败");
                            Value::Null
                        }
                    }
                } else {
                    Value::Null
                };
                let _ = event_tx_prompt.send(crate::acp_bridge::SseEvent {
                    event: "run.failed".into(),
                    data: json!({ "error": e.to_string(), "agentAudit": agent_audit }),
                });
            }
        }
        let was_aborted = matches!(
            runtime_status_for_run(&run_id_owned).as_deref(),
            Some("aborting" | "aborted")
        );
        mgr_clone.close().await;
        ACP_ACTIVE_RUNS
            .lock()
            .expect("acp active runs")
            .remove(&run_id_owned);
        if !was_aborted {
            update_runtime_by_run_id(&run_id_owned, "completed", Some("acp.prompt.done"), None);
        }
    });

    let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
    let mut response = Response::builder()
        .status(StatusCode::OK)
        .header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
        .header(header::CACHE_CONTROL, "no-cache, no-transform")
        .header("x-accel-buffering", "no")
        .body(Body::from_stream(stream))
        .map_err(|e| {
            WebError::internal(format!("SSE response build failed: {e}")).with_context(&context)
        })?;
    stamp_client_headers_into(response.headers_mut());
    Ok(response)
}

pub async fn stream_events(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Path(run_id): Path<String>,
) -> Result<Response, WebError> {
    ensure_authenticated(&context)?;
    let profile = profile_for_run(&run_id).unwrap_or_else(|| "default".into());

    // ACP path: start AcpRunBridge and return SSE stream
    if let Some(acp_runtime_name) = acp_runtime_for_run(&run_id, &profile) {
        return acp_stream_events(state, context, &run_id, &profile, &acp_runtime_name).await;
    }

    let Some(upstream) = configured_upstream_for_profile(&profile) else {
        return Err(hermes_unconfigured_error(&context));
    };
    let url = upstream_url(
        &upstream,
        &format!("/v1/runs/{}/events", url_escape(&run_id)),
    )?;
    let mut request = reqwest::Client::builder()
        .timeout(Duration::from_secs(1800))
        .build()
        .map_err(|error| {
            WebError::internal(format!("Hermes client 构造失败: {error}")).with_context(&context)
        })?
        .get(url);
    if let Some(api_key) = configured_api_key_for_profile(&profile) {
        request = request.bearer_auth(api_key);
    }
    let upstream_response = request.send().await.map_err(|error| {
        WebError::bad_gateway_code(
            "hermes_client_upstream_unavailable",
            format!("Hermes events upstream 连接失败: {error}"),
        )
        .with_context(&context)
        .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
    })?;
    if !upstream_response.status().is_success() {
        let status = upstream_response.status();
        let text = upstream_response.text().await.unwrap_or_default();
        return Err(upstream_error(&context, status, text));
    }

    let run_id_for_stream = run_id.clone();
    let context_for_queue = context.clone();
    update_runtime_by_run_id(&run_id, "running", Some("events.connected"), None);
    let stream = upstream_response
        .bytes_stream()
        .map_ok(move |chunk| {
            let text = String::from_utf8_lossy(&chunk);
            let (normalized, terminal_sessions) =
                normalize_sse_chunk(&run_id_for_stream, text.as_ref());
            for session_id in terminal_sessions {
                let context = context_for_queue.clone();
                tokio::spawn(async move {
                    start_next_queued_run(context, session_id).await;
                });
            }
            axum::body::Bytes::from(normalized)
        })
        .map_err(|error| {
            std::io::Error::new(
                std::io::ErrorKind::Other,
                format!("Hermes events stream 读取失败: {error}"),
            )
        });
    let mut response = Response::builder()
        .status(StatusCode::OK)
        .header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
        .header(header::CACHE_CONTROL, "no-cache, no-transform")
        .header("x-accel-buffering", "no")
        .body(Body::from_stream(stream))
        .map_err(|error| WebError::internal(format!("Hermes events 响应构造失败: {error}")))?;
    stamp_client_headers_into(response.headers_mut());
    Ok(response)
}

pub async fn abort_run(
    Extension(context): Extension<RequestContext>,
    Path(run_id): Path<String>,
    Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let profile = profile_for_run(&run_id).unwrap_or_else(|| "default".into());
    if acp_runtime_for_run(&run_id, &profile).is_some() {
        let active = ACP_ACTIVE_RUNS
            .lock()
            .expect("acp active runs")
            .remove(&run_id);
        update_runtime_by_run_id(&run_id, "aborting", Some("abort.started"), None);
        let Some(active) = active else {
            update_runtime_by_run_id(&run_id, "aborted", Some("abort.missing_active_run"), None);
            return Ok((
                StatusCode::OK,
                stamp_client_headers(),
                Json(json!({
                    "ok": true,
                    "runId": run_id,
                    "status": "aborted",
                    "runtime": runtime_state_for_run(&run_id).unwrap_or(Value::Null),
                    "events": [
                        {"event": "abort.started", "runId": run_id},
                        {"event": "abort.completed", "runId": run_id, "note": "active ACP run was already finished or missing"}
                    ]
                })),
            ));
        };
        let cancel_result = tokio::time::timeout(
            Duration::from_millis(ACP_ABORT_NOTIFICATION_TIMEOUT_MS),
            active.manager.cancel(),
        )
        .await;
        let cancel_status = match cancel_result {
            Ok(Ok(())) => json!({"ok": true}),
            Ok(Err(error)) => {
                warn!(error = ?error, run_id = %run_id, "ACP abort notification failed; marking run aborted");
                json!({"ok": false, "error": error.to_string()})
            }
            Err(_) => {
                warn!(
                    run_id = %run_id,
                    timeout_ms = ACP_ABORT_NOTIFICATION_TIMEOUT_MS,
                    "ACP abort notification timed out; marking run aborted"
                );
                json!({"ok": false, "error": "abort notification timed out"})
            }
        };
        update_runtime_by_run_id(&run_id, "aborted", Some("abort.completed"), None);
        let _ = active.event_tx.send(crate::acp_bridge::SseEvent {
            event: "run.aborted".into(),
            data: json!({
                "reason": payload
                    .get("reason")
                    .and_then(Value::as_str)
                    .unwrap_or("client_abort"),
                "cancel": cancel_status.clone(),
            }),
        });
        return Ok((
            StatusCode::OK,
            stamp_client_headers(),
            Json(json!({
                "ok": true,
                "runId": run_id,
                "sessionId": active.mnote_session_id,
                "acpSessionId": active.acp_session_id,
                "status": "aborted",
                "runtime": runtime_state_for_run(&run_id).unwrap_or(Value::Null),
                "cancel": cancel_status,
                "events": [
                    {"event": "abort.started", "runId": run_id},
                    {"event": "abort.completed", "runId": run_id}
                ]
            })),
        ));
    }
    let Some(upstream) = configured_upstream_for_profile(&profile) else {
        return hermes_unconfigured(&context);
    };
    let queued_session_id = session_id_for_run(&run_id);
    update_runtime_by_run_id(&run_id, "aborting", Some("abort.started"), None);
    let result = proxy_json(
        &context,
        reqwest::Method::POST,
        &upstream,
        &format!("/v1/runs/{}/stop", url_escape(&run_id)),
        Some(payload),
        Some(&profile),
    )
    .await;
    match result {
        Ok((status, headers, Json(mut payload))) => {
            update_runtime_by_run_id(&run_id, "aborted", Some("abort.completed"), None);
            payload["runtime"] = runtime_state_for_run(&run_id).unwrap_or(Value::Null);
            payload["events"] = json!([
                {"event": "abort.started", "runId": run_id},
                {"event": "abort.completed", "runId": run_id}
            ]);
            if let Some(session_id) = queued_session_id {
                start_next_queued_run(context.clone(), session_id).await;
            }
            Ok((status, headers, Json(payload)))
        }
        Err(error) => {
            update_runtime_by_run_id(&run_id, "failed", Some("abort.failed"), None);
            Err(error)
        }
    }
}

/// Resolve a pending permission request from an ACP agent.
///
/// POST /api/hermes/client/runs/{run_id}/resolve-permission
/// Body: { "permissionId": "...", "decision": "allow"|"deny" }
///
/// Looks up the active run, forwards the decision to the ACP subprocess,
/// and emits `permission.allowed` / `permission.denied` SSE event.
pub async fn resolve_permission(
    Extension(context): Extension<RequestContext>,
    Path(run_id): Path<String>,
    Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let permission_id = payload
        .get("permissionId")
        .and_then(Value::as_str)
        .filter(|s| !s.trim().is_empty())
        .ok_or_else(|| {
            WebError::bad_request_code("hermes_client_bad_request", "缺少 permissionId")
                .with_context(&context)
        })?;
    let decision = payload
        .get("decision")
        .and_then(Value::as_str)
        .filter(|s| !s.trim().is_empty())
        .ok_or_else(|| {
            WebError::bad_request_code("hermes_client_bad_request", "缺少 decision (allow/deny)")
                .with_context(&context)
        })?;

    let profile = profile_for_run(&run_id).unwrap_or_else(|| "default".into());
    if acp_runtime_for_run(&run_id, &profile).is_none() {
        return Err(WebError::bad_request_code(
            "hermes_client_not_acp_run",
            format!("run_id={run_id} 不是 ACP run"),
        )
        .with_context(&context));
    }

    let active = ACP_ACTIVE_RUNS
        .lock()
        .expect("acp active runs")
        .get(&run_id)
        .cloned();
    let Some(active) = active else {
        return Err(WebError::bad_request_code(
            "hermes_client_no_active_run",
            format!("run_id={run_id} 没有活跃 ACP run"),
        )
        .with_context(&context));
    };

    active
        .manager
        .resolve_permission(permission_id, decision)
        .await
        .map_err(|e| {
            WebError::bad_request_code(
                "hermes_client_permission_resolve_failed",
                format!("resolve permission 失败: {e}"),
            )
            .with_context(&context)
        })?;

    Ok((
        StatusCode::OK,
        stamp_client_headers(),
        Json(json!({
            "ok": true,
            "runId": run_id,
            "permissionId": permission_id,
            "decision": decision,
        })),
    ))
}

pub async fn list_models(
    Extension(context): Extension<RequestContext>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let Some(upstream) = configured_upstream() else {
        return hermes_unconfigured(&context);
    };
    proxy_json(
        &context,
        reqwest::Method::GET,
        &upstream,
        "/v1/models",
        None,
        None,
    )
    .await
}

pub async fn list_tools(
    Extension(context): Extension<RequestContext>,
    Query(query): Query<HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into());
    let profile = query
        .get("profile")
        .map(String::as_str)
        .unwrap_or(fallback_profile.as_str());
    Ok((
        StatusCode::OK,
        stamp_client_headers(),
        Json(json!({
            "ok": true,
            "traceId": context.trace.trace_id,
            "profile": profile,
            "tools": mnote_tools_payload(profile)
        })),
    ))
}

fn hermes_bin() -> String {
    std::env::var("MNOTE_WEB_HERMES_BIN")
        .ok()
        .filter(|value| !value.trim().is_empty())
        .unwrap_or_else(|| "hermes".into())
}

fn hermes_home() -> PathBuf {
    std::env::var("HERMES_HOME")
        .ok()
        .filter(|value| !value.trim().is_empty())
        .map(PathBuf::from)
        .or_else(|| {
            std::env::var("HOME")
                .ok()
                .map(|home| PathBuf::from(home).join(".hermes"))
        })
        .unwrap_or_else(|| PathBuf::from(".hermes"))
}

pub(crate) fn active_profile_name() -> Option<String> {
    fs::read_to_string(hermes_home().join("active_profile"))
        .ok()
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
}

fn profile_home(profile: &str) -> PathBuf {
    let home = hermes_home();
    let profile = profile.trim();
    if profile.is_empty() || profile == "default" {
        return home;
    }
    let candidate = home.join("profiles").join(profile);
    if candidate.exists() {
        candidate
    } else {
        home
    }
}

fn profile_config_path(profile: &str) -> PathBuf {
    profile_home(profile).join("config.yaml")
}

fn parse_profile_list(stdout: &str) -> Vec<Value> {
    let active = active_profile_name().unwrap_or_else(|| "default".into());
    stdout
        .replace("\r\n", "\n")
        .replace('\r', "\n")
        .lines()
        .filter_map(|line| {
            let trimmed = line.trim();
            if trimmed.is_empty() || trimmed.starts_with("Profile") || trimmed.starts_with('─') {
                return None;
            }
            let is_active = trimmed.starts_with('◆');
            let without_marker = trimmed.trim_start_matches('◆').trim();
            let parts = without_marker
                .split_whitespace()
                .map(str::to_string)
                .collect::<Vec<_>>();
            if parts.is_empty() {
                return None;
            }
            let name = parts[0].clone();
            let model = parts
                .get(1)
                .filter(|value| value.as_str() != "—")
                .cloned()
                .unwrap_or_default();
            let gateway = parts
                .get(2)
                .filter(|value| value.as_str() != "—")
                .cloned()
                .unwrap_or_default();
            let alias = parts
                .get(3)
                .filter(|value| value.as_str() != "—")
                .cloned()
                .unwrap_or_default();
            Some(json!({
                "name": name,
                "active": name == active || (active == "default" && is_active),
                "model": model,
                "gateway": gateway,
                "alias": alias
            }))
        })
        .collect()
}

fn fallback_profiles() -> Vec<Value> {
    let active = active_profile_name().unwrap_or_else(|| "default".into());
    let default_status = profile_gateway_status("default");
    let mut profiles = vec![json!({
        "name": "default",
        "active": active == "default",
        "model": default_status["modelDefault"].as_str().unwrap_or_default(),
        "gateway": default_status["gateway"].as_str().unwrap_or_default(),
        "provider": default_status["provider"].as_str().unwrap_or_default(),
        "alias": ""
    })];
    let profiles_dir = hermes_home().join("profiles");
    if let Ok(entries) = fs::read_dir(profiles_dir) {
        let mut names = entries
            .filter_map(Result::ok)
            .filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false))
            .filter_map(|entry| entry.file_name().into_string().ok())
            .collect::<Vec<_>>();
        names.sort();
        profiles.extend(names.into_iter().map(|name| {
            let status = profile_gateway_status(&name);
            json!({
                "name": name,
                "active": name == active,
                "model": status["modelDefault"].as_str().unwrap_or_default(),
                "gateway": status["gateway"].as_str().unwrap_or_default(),
                "provider": status["provider"].as_str().unwrap_or_default(),
                "alias": ""
            })
        }));
    }
    profiles
}

fn list_profiles_payload() -> Value {
    let profiles = Command::new(hermes_bin())
        .args(["profile", "list"])
        .output()
        .ok()
        .filter(|output| output.status.success())
        .and_then(|output| String::from_utf8(output.stdout).ok())
        .map(|stdout| parse_profile_list(&stdout))
        .filter(|profiles| !profiles.is_empty())
        .unwrap_or_else(fallback_profiles);
    let reasonix_model = std::env::var("REASONIX_MODEL").unwrap_or_else(|_| "deepseek-chat".into());
    let reasonix_preset = std::env::var("REASONIX_PRESET").unwrap_or_else(|_| "auto".into());
    let reasonix_has_key = std::env::var("DEEPSEEK_API_KEY").is_ok();
    json!({
        "ok": true,
        "profiles": profiles,
        "acpRuntimes": json!([
            {
                "name": "hermes",
                "title": "ACP · Hermes",
                "description": "通过 ACP 协议直连 Hermes agent runtime · model/default 来自 Hermes profile"
            },
            {
                "name": "reasonix",
                "title": "ACP · Reasonix",
                "description": "通过 ACP 协议直连 Reasonix（DeepSeek 缓存优先）",
                "model": reasonix_model,
                "preset": reasonix_preset,
                "apiKeyConfigured": reasonix_has_key,
                "version": "0.43.0"
            }
        ])
    })
}

fn profile_detail_payload(profile: &str) -> Value {
    let dir = profile_home(profile);
    let gateway_status = profile_gateway_status(profile);
    json!({
        "name": if profile.trim().is_empty() { "default" } else { profile.trim() },
        "path": dir.to_string_lossy(),
        "model": gateway_status["modelDefault"].as_str().unwrap_or_default(),
        "provider": gateway_status["provider"].as_str().unwrap_or_default(),
        "gateway": gateway_status["gateway"].as_str().unwrap_or_default(),
        "modelConfigured": gateway_status["modelConfigured"].as_bool().unwrap_or(false),
        "apiKeyConfigured": gateway_status["apiKeyConfigured"].as_bool().unwrap_or(false),
        "suggestions": gateway_status["suggestions"].clone(),
        "skills": skills_payload(profile)["categories"].as_array().map(Vec::len).unwrap_or_default(),
        "hasEnv": dir.join(".env").exists(),
        "hasSoulMd": dir.join("SOUL.md").exists()
    })
}

fn switch_active_profile_local(profile: &str) -> std::io::Result<()> {
    let command_result = Command::new(hermes_bin())
        .args(["profile", "use", profile])
        .output();
    if command_result
        .as_ref()
        .map(|output| output.status.success())
        .unwrap_or(false)
    {
        return Ok(());
    }
    let home = hermes_home();
    fs::create_dir_all(&home)?;
    if profile != "default" {
        fs::create_dir_all(home.join("profiles").join(profile))?;
    }
    fs::write(home.join("active_profile"), profile.as_bytes())
}

fn file_text(path: &FsPath) -> String {
    fs::read_to_string(path).unwrap_or_default()
}

fn file_mtime_ms(path: &FsPath) -> Option<i128> {
    let modified = fs::metadata(path).ok()?.modified().ok()?;
    let duration = modified.duration_since(std::time::UNIX_EPOCH).ok()?;
    Some(duration.as_millis() as i128)
}

fn profile_memory_payload(profile: &str) -> Value {
    let dir = profile_home(profile);
    let memory_path = dir.join("memories").join("MEMORY.md");
    let user_path = dir.join("memories").join("USER.md");
    let soul_path = dir.join("SOUL.md");
    json!({
        "ok": true,
        "memory": file_text(&memory_path),
        "user": file_text(&user_path),
        "soul": file_text(&soul_path),
        "memory_mtime": file_mtime_ms(&memory_path),
        "user_mtime": file_mtime_ms(&user_path),
        "soul_mtime": file_mtime_ms(&soul_path)
    })
}

fn save_profile_memory_local(profile: &str, section: &str, content: &str) -> std::io::Result<()> {
    let dir = profile_home(profile);
    let path = match section {
        "soul" => dir.join("SOUL.md"),
        "user" => dir.join("memories").join("USER.md"),
        _ => dir.join("memories").join("MEMORY.md"),
    };
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(path, content)
}

fn disabled_skills(profile: &str) -> Vec<String> {
    let content = fs::read_to_string(profile_config_path(profile)).unwrap_or_default();
    let mut disabled = Vec::new();
    let mut in_skills = false;
    let mut in_disabled = false;
    for line in content.lines() {
        let trimmed = line.trim();
        if !line.starts_with(' ') && !trimmed.is_empty() {
            in_skills = trimmed == "skills:";
            in_disabled = false;
            continue;
        }
        if in_skills && trimmed == "disabled:" {
            in_disabled = true;
            continue;
        }
        if in_disabled {
            if let Some(value) = trimmed.strip_prefix("- ") {
                disabled.push(
                    value
                        .trim()
                        .trim_matches('"')
                        .trim_matches('\'')
                        .to_string(),
                );
            } else if !trimmed.is_empty() && !trimmed.starts_with('#') {
                in_disabled = false;
            }
        }
    }
    disabled
}

fn yaml_disabled_list(content: &str, path: &[&str]) -> Vec<String> {
    let mut stack: Vec<(usize, String)> = Vec::new();
    let mut values = Vec::new();
    for raw_line in content.lines() {
        let line = raw_line.trim_end_matches('\r');
        let trimmed = line.trim();
        if trimmed.is_empty() || trimmed.starts_with('#') {
            continue;
        }
        let indent = line.chars().take_while(|ch| ch.is_whitespace()).count();
        if let Some(value) = trimmed.strip_prefix("- ") {
            let keys = stack
                .iter()
                .map(|(_, key)| key.as_str())
                .collect::<Vec<_>>();
            if keys == path {
                let normalized = value.trim().trim_matches('"').trim_matches('\'');
                if !normalized.is_empty() {
                    values.push(normalized.to_string());
                }
            }
            continue;
        }
        while stack
            .last()
            .map(|(level, _)| *level >= indent)
            .unwrap_or(false)
        {
            stack.pop();
        }
        let Some((key, _value)) = trimmed.split_once(':') else {
            continue;
        };
        let key = key.trim().trim_matches('"').trim_matches('\'').to_string();
        stack.push((indent, key));
    }
    values
}

pub(crate) fn disabled_mnote_tools(profile: &str) -> Vec<String> {
    let content = fs::read_to_string(profile_config_path(profile)).unwrap_or_default();
    yaml_disabled_list(&content, &["mnote", "tools", "disabled"])
}

pub(crate) fn is_mnote_tool_disabled(profile: &str, name: &str) -> bool {
    disabled_mnote_tools(profile)
        .iter()
        .any(|item| item == name)
}

fn mnote_tools_payload(profile: &str) -> Vec<Value> {
    let disabled = disabled_mnote_tools(profile);
    manifest::manifest()
        .get("tools")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .filter_map(|tool| mnote_tool_entry(tool, &disabled))
        .collect()
}

fn mnote_tool_entry(tool: &Value, disabled: &[String]) -> Option<Value> {
    let name = tool.get("name").and_then(Value::as_str)?.trim();
    if name.is_empty() {
        return None;
    }
    let capability_scope = tool
        .get("capabilityScope")
        .and_then(Value::as_array)
        .map(|items| {
            items
                .iter()
                .filter_map(Value::as_str)
                .filter(|value| !value.trim().is_empty())
                .map(str::to_string)
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();
    let disabled = disabled.iter().any(|item| item == name);
    let status = if disabled {
        "disabled"
    } else {
        tool.get("status")
            .and_then(Value::as_str)
            .unwrap_or("available")
    };
    Some(json!({
        "name": name,
        "description": tool.get("description").and_then(Value::as_str).unwrap_or_default(),
        "scope": capability_scope.first().cloned().unwrap_or_else(|| "mnote".into()),
        "capabilityScope": capability_scope,
        "kind": if capability_scope.iter().any(|scope| scope.ends_with(".write")) { "write" } else { "read" },
        "schemaVersion": tool.get("schemaVersion").and_then(Value::as_str).unwrap_or(manifest::TOOL_SCHEMA_VERSION),
        "status": status,
        "enabled": !disabled,
        "unavailableReason": if disabled { "当前 Hermes profile 已关闭该 mnote tool" } else { "" }
    }))
}

fn extract_skill_description(markdown: &str) -> String {
    markdown
        .lines()
        .find_map(|line| {
            let trimmed = line.trim();
            if trimmed.is_empty() || trimmed.starts_with('#') {
                return None;
            }
            Some(
                trimmed
                    .trim_start_matches("Description:")
                    .trim()
                    .to_string(),
            )
        })
        .unwrap_or_default()
}

struct SkillCatalogMeta {
    bundled: HashSet<String>,
    hub_installed: HashSet<String>,
    usage: HashMap<String, Value>,
}

impl SkillCatalogMeta {
    fn read(skills_dir: &FsPath) -> Self {
        Self {
            bundled: read_bundled_skill_names(&skills_dir.join(".bundled_manifest")),
            hub_installed: read_hub_installed_skill_names(
                &skills_dir.join(".hub").join("lock.json"),
            ),
            usage: read_skill_usage(&skills_dir.join(".usage.json")),
        }
    }

    fn usage_for(&self, name: &str) -> Option<&Value> {
        self.usage.get(name)
    }
}

fn read_bundled_skill_names(path: &FsPath) -> HashSet<String> {
    fs::read_to_string(path)
        .ok()
        .map(|content| {
            content
                .lines()
                .filter_map(|line| {
                    line.split_once(':')
                        .map(|(name, _)| name.trim().to_string())
                })
                .filter(|name| !name.is_empty())
                .collect()
        })
        .unwrap_or_default()
}

fn read_hub_installed_skill_names(path: &FsPath) -> HashSet<String> {
    let Ok(content) = fs::read_to_string(path) else {
        return HashSet::new();
    };
    let Ok(payload) = serde_json::from_str::<Value>(&content) else {
        return HashSet::new();
    };
    payload
        .get("installed")
        .and_then(Value::as_object)
        .map(|installed| {
            installed
                .iter()
                .flat_map(|(name, entry)| {
                    let mut names = vec![name.to_string()];
                    if let Some(path) = entry.get("install_path").and_then(Value::as_str) {
                        if let Some(last) = path.rsplit('/').find(|part| !part.trim().is_empty()) {
                            names.push(last.to_string());
                        }
                    }
                    names
                })
                .collect()
        })
        .unwrap_or_default()
}

fn read_skill_usage(path: &FsPath) -> HashMap<String, Value> {
    let Ok(content) = fs::read_to_string(path) else {
        return HashMap::new();
    };
    serde_json::from_str::<Value>(&content)
        .ok()
        .and_then(|payload| payload.as_object().cloned())
        .map(|items| items.into_iter().collect())
        .unwrap_or_default()
}

fn skill_source(name: &str, meta: &SkillCatalogMeta) -> &'static str {
    if meta.bundled.contains(name) {
        "builtin"
    } else if meta.hub_installed.contains(name) {
        "hub"
    } else {
        "local"
    }
}

fn skill_is_agent_generated(usage: Option<&Value>) -> bool {
    usage
        .and_then(Value::as_object)
        .map(|record| {
            record
                .get("created_by")
                .and_then(Value::as_str)
                .map(|value| value == "agent")
                .unwrap_or(false)
                || record
                    .get("agent_created")
                    .and_then(Value::as_bool)
                    .unwrap_or(false)
        })
        .unwrap_or(false)
}

fn skill_origin(source: &str, usage: Option<&Value>) -> &'static str {
    if skill_is_agent_generated(usage) {
        "generated"
    } else if source == "builtin" {
        "builtin"
    } else if source == "hub" {
        "installed"
    } else {
        "copied"
    }
}

fn skill_entry(
    name: String,
    category: String,
    path: PathBuf,
    disabled: &[String],
    meta: &SkillCatalogMeta,
) -> Option<Value> {
    let content = fs::read_to_string(path).ok()?;
    let source = skill_source(&name, meta);
    let usage = meta.usage_for(&name);
    Some(json!({
        "name": name,
        "description": extract_skill_description(&content),
        "enabled": !disabled.iter().any(|item| item == &name),
        "source": source,
        "origin": skill_origin(source, usage),
        "createdBy": usage.and_then(|record| record.get("created_by")).and_then(Value::as_str),
        "createdAt": usage.and_then(|record| record.get("created_at")).and_then(Value::as_str),
        "patchCount": usage.and_then(|record| record.get("patch_count")).and_then(Value::as_i64),
        "category": category
    }))
}

fn skills_payload(profile: &str) -> Value {
    let skills_dir = hermes_home().join("skills");
    let disabled = disabled_skills(profile);
    let meta = SkillCatalogMeta::read(&skills_dir);
    let mut categories = Vec::new();
    let Ok(entries) = fs::read_dir(&skills_dir) else {
        return json!({"ok": true, "categories": [], "archived": []});
    };
    let mut dirs = entries
        .filter_map(Result::ok)
        .filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false))
        .filter(|entry| !entry.file_name().to_string_lossy().starts_with('.'))
        .collect::<Vec<_>>();
    dirs.sort_by_key(|entry| entry.file_name());
    for entry in dirs {
        let name = entry.file_name().to_string_lossy().to_string();
        let dir = entry.path();
        let direct_skill = dir.join("SKILL.md");
        if direct_skill.exists() {
            if let Some(skill) = skill_entry(name, "misc".into(), direct_skill, &disabled, &meta) {
                categories.push(json!({
                    "name": "misc",
                    "description": "misc",
                    "skills": [skill]
                }));
            }
            continue;
        }
        let mut skills = fs::read_dir(&dir)
            .ok()
            .into_iter()
            .flat_map(|entries| entries.filter_map(Result::ok))
            .filter(|child| child.file_type().map(|kind| kind.is_dir()).unwrap_or(false))
            .filter_map(|child| {
                let skill_name = child.file_name().to_string_lossy().to_string();
                skill_entry(
                    skill_name,
                    name.clone(),
                    child.path().join("SKILL.md"),
                    &disabled,
                    &meta,
                )
            })
            .collect::<Vec<_>>();
        if !skills.is_empty() {
            skills.sort_by_key(|skill| skill["name"].as_str().unwrap_or_default().to_string());
            categories.push(json!({
                "name": name,
                "description": "",
                "skills": skills
            }));
        }
    }
    json!({
        "ok": true,
        "categories": merge_misc_categories(categories),
        "archived": []
    })
}

fn reasonix_skill_roots() -> Vec<(PathBuf, &'static str)> {
    let mut roots = Vec::new();
    let project_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .ancestors()
        .nth(3)
        .unwrap_or(std::path::Path::new("/mnt/Data1T/mnote"))
        .to_path_buf();
    roots.push((project_root.join(".reasonix").join("skills"), "project"));
    roots.push((project_root.join(".agents").join("skills"), "project"));
    if let Ok(home) = std::env::var("HOME") {
        let home = PathBuf::from(home);
        roots.push((home.join(".reasonix").join("skills"), "global"));
        roots.push((home.join(".agents").join("skills"), "global"));
    }
    roots
}

fn reasonix_skill_entry(path: PathBuf, stem: String, scope: &str) -> Option<Value> {
    let content = fs::read_to_string(&path).ok()?;
    let metadata = parse_reasonix_skill_frontmatter(&content);
    let name = metadata
        .get("name")
        .and_then(Value::as_str)
        .filter(|value| !value.trim().is_empty())
        .unwrap_or(&stem)
        .to_string();
    Some(json!({
        "name": name,
        "description": metadata
            .get("description")
            .and_then(Value::as_str)
            .filter(|value| !value.trim().is_empty())
            .map(ToOwned::to_owned)
            .unwrap_or_else(|| extract_skill_description(&content)),
        "enabled": true,
        "toggleable": false,
        "source": "reasonix",
        "origin": scope,
        "category": scope,
        "path": path.to_string_lossy(),
        "scope": scope,
        "runAs": metadata.get("runAs").cloned().unwrap_or(Value::Null),
        "model": metadata.get("model").cloned().unwrap_or(Value::Null)
    }))
}

fn parse_reasonix_skill_frontmatter(markdown: &str) -> serde_json::Map<String, Value> {
    let mut metadata = serde_json::Map::new();
    let mut lines = markdown.lines();
    if lines.next().map(str::trim) != Some("---") {
        return metadata;
    }
    for line in lines {
        let trimmed = line.trim();
        if trimmed == "---" {
            break;
        }
        let Some((key, value)) = trimmed.split_once(':') else {
            continue;
        };
        let normalized = value.trim().trim_matches('"').trim_matches('\'');
        if !key.trim().is_empty() && !normalized.is_empty() {
            metadata.insert(
                key.trim().to_string(),
                Value::String(normalized.to_string()),
            );
        }
    }
    metadata
}

fn reasonix_skills_payload() -> Value {
    let mut seen = HashSet::new();
    let mut by_scope: HashMap<String, Vec<Value>> = HashMap::new();
    for (root, scope) in reasonix_skill_roots() {
        let Ok(entries) = fs::read_dir(&root) else {
            continue;
        };
        let mut entries = entries.filter_map(Result::ok).collect::<Vec<_>>();
        entries.sort_by_key(|entry| entry.file_name());
        for entry in entries {
            let file_name = entry.file_name().to_string_lossy().to_string();
            let path = entry.path();
            let skill = if entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) {
                reasonix_skill_entry(path.join("SKILL.md"), file_name, scope)
            } else if entry
                .file_type()
                .map(|kind| kind.is_file())
                .unwrap_or(false)
                && file_name.ends_with(".md")
            {
                let stem = file_name.trim_end_matches(".md").to_string();
                reasonix_skill_entry(path, stem, scope)
            } else {
                None
            };
            let Some(skill) = skill else {
                continue;
            };
            let Some(name) = skill.get("name").and_then(Value::as_str) else {
                continue;
            };
            if !seen.insert(name.to_string()) {
                continue;
            }
            by_scope.entry(scope.to_string()).or_default().push(skill);
        }
    }
    let mut categories = by_scope
        .into_iter()
        .map(|(scope, mut skills)| {
            skills.sort_by_key(|skill| skill["name"].as_str().unwrap_or_default().to_string());
            json!({
                "name": scope,
                "description": format!("Reasonix {scope} skills"),
                "skills": skills
            })
        })
        .collect::<Vec<_>>();
    categories.sort_by_key(
        |category| match category["name"].as_str().unwrap_or_default() {
            "project" => 0,
            "custom" => 1,
            "global" => 2,
            _ => 3,
        },
    );
    json!({
        "ok": true,
        "runtime": "reasonix",
        "categories": categories,
        "archived": []
    })
}

fn merge_misc_categories(categories: Vec<Value>) -> Vec<Value> {
    let mut merged = Vec::new();
    let mut misc = Vec::new();
    for category in categories {
        if category["name"].as_str() == Some("misc") {
            if let Some(skills) = category["skills"].as_array() {
                misc.extend(skills.iter().cloned());
            }
        } else {
            merged.push(category);
        }
    }
    if !misc.is_empty() {
        misc.sort_by_key(|skill| skill["name"].as_str().unwrap_or_default().to_string());
        merged.insert(
            0,
            json!({
                "name": "misc",
                "description": "misc",
                "skills": misc
            }),
        );
    }
    merged
}

fn set_skill_enabled(profile: &str, name: &str, enabled: bool) -> std::io::Result<()> {
    let path = profile_config_path(profile);
    let mut disabled = disabled_skills(profile);
    if enabled {
        disabled.retain(|item| item != name);
    } else if !disabled.iter().any(|item| item == name) {
        disabled.push(name.to_string());
    }
    disabled.sort();
    let existing = fs::read_to_string(&path).unwrap_or_default();
    let mut kept = Vec::new();
    let mut in_skills = false;
    let mut in_disabled = false;
    for line in existing.lines() {
        let trimmed = line.trim();
        if !line.starts_with(' ') && !trimmed.is_empty() {
            in_skills = trimmed == "skills:";
            in_disabled = false;
        }
        if in_skills && trimmed == "disabled:" {
            in_disabled = true;
            continue;
        }
        if in_disabled {
            if trimmed.starts_with("- ") || trimmed.is_empty() {
                continue;
            }
            in_disabled = false;
        }
        kept.push(line.to_string());
    }
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    let mut output = kept.join("\n");
    if !output.ends_with('\n') && !output.is_empty() {
        output.push('\n');
    }
    output.push_str("skills:\n  disabled:\n");
    for skill in disabled {
        output.push_str("    - ");
        output.push_str(&skill);
        output.push('\n');
    }
    fs::write(path, output)
}

fn set_mnote_tool_enabled(profile: &str, name: &str, enabled: bool) -> std::io::Result<()> {
    let path = profile_config_path(profile);
    let mut disabled = disabled_mnote_tools(profile);
    if enabled {
        disabled.retain(|item| item != name);
    } else if !disabled.iter().any(|item| item == name) {
        disabled.push(name.to_string());
    }
    disabled.sort();
    let existing = fs::read_to_string(&path).unwrap_or_default();
    let mut kept = Vec::new();
    let mut stack: Vec<(usize, String)> = Vec::new();
    let mut skipping_disabled_list = false;
    let mut disabled_indent = 0usize;
    for line in existing.lines() {
        let trimmed = line.trim();
        let indent = line.chars().take_while(|ch| ch.is_whitespace()).count();
        if skipping_disabled_list {
            if trimmed.starts_with("- ") && indent > disabled_indent {
                continue;
            }
            skipping_disabled_list = false;
        }
        if trimmed.is_empty() || trimmed.starts_with('#') {
            kept.push(line.to_string());
            continue;
        }
        if !trimmed.starts_with("- ") {
            while stack
                .last()
                .map(|(level, _)| *level >= indent)
                .unwrap_or(false)
            {
                stack.pop();
            }
            if let Some((key, _value)) = trimmed.split_once(':') {
                let key = key.trim().trim_matches('"').trim_matches('\'').to_string();
                stack.push((indent, key));
                let keys = stack
                    .iter()
                    .map(|(_, key)| key.as_str())
                    .collect::<Vec<_>>();
                if keys == ["mnote", "tools", "disabled"] {
                    skipping_disabled_list = true;
                    disabled_indent = indent;
                    continue;
                }
            }
        }
        kept.push(line.to_string());
    }
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    let mut output = kept.join("\n");
    if !output.ends_with('\n') && !output.is_empty() {
        output.push('\n');
    }
    output.push_str("mnote:\n  tools:\n    disabled:\n");
    for tool in disabled {
        output.push_str("      - ");
        output.push_str(&tool);
        output.push('\n');
    }
    fs::write(path, output)
}

fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> {
    let has_actor = context.auth.actor_id.trim() != "anonymous";
    if has_actor || context.auth.authorization.is_some() || context.auth.cookie_header.is_some() {
        return Ok(());
    }
    Err(WebError::new(
        StatusCode::UNAUTHORIZED,
        "hermes_client_unauthorized",
        "页面 AI Hermes client 需要登录后访问",
    )
    .with_context(context)
    .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
    .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client"))
}

fn configured_upstream() -> Option<String> {
    [
        "MNOTE_WEB_HERMES_UPSTREAM_URL",
        "MNOTE_HERMES_UPSTREAM_URL",
        "MNOTE_HERMES_API_BASE_URL",
    ]
    .into_iter()
    .find_map(env_or_dotenv)
    .map(|value| value.trim().trim_end_matches('/').to_string())
    .filter(|value| !value.is_empty())
}

fn profile_config_value(profile: &str, path: &[&str]) -> Option<String> {
    let content = fs::read_to_string(profile_config_path(profile)).ok()?;
    yaml_path_value(&content, path)
}

fn profile_env_key(prefix: &str, profile: &str, suffix: &str) -> String {
    let normalized = profile
        .chars()
        .map(|ch| {
            if ch.is_ascii_alphanumeric() {
                ch.to_ascii_uppercase()
            } else {
                '_'
            }
        })
        .collect::<String>();
    format!("{prefix}_{normalized}_{suffix}")
}

fn hermes_http_proxy_enabled() -> bool {
    [
        "MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY",
        "MNOTE_ENABLE_HERMES_HTTP_PROXY",
    ]
    .into_iter()
    .find_map(env_or_dotenv)
    .map(|value| {
        matches!(
            value.trim().to_ascii_lowercase().as_str(),
            "1" | "true" | "yes"
        )
    })
    .unwrap_or(false)
}

fn default_acp_runtime_name() -> String {
    env_or_dotenv("MNOTE_WEB_ACP_DEFAULT_RUNTIME")
        .map(|value| value.trim().to_ascii_lowercase())
        .filter(|value| value == "hermes" || value == "reasonix")
        .unwrap_or_else(|| "reasonix".into())
}

/// 判断当前 profile 是否应走 ACP，而不是已退役的 Hermes HTTP proxy。
///
/// Hermes HTTP 不再是页面 AI 默认主路径；仅在显式兼容开关打开时，
/// 给尚未迁移的调用方保留短期入口。
fn is_acp_profile(profile: &str) -> bool {
    if !hermes_http_proxy_enabled() {
        return true;
    }
    // ACP runtimes: "reasonix" and "hermes" both use ACP protocol when selected from the UI.
    // The "reasonix" name is hardcoded; "hermes" as ACP is triggered by env var or UI selection.
    if profile == "reasonix" || profile == "hermes" {
        return true;
    }
    let env_key = profile_env_key("MNOTE_WEB", profile, "RUNTIME_TYPE");
    env_or_dotenv(&env_key)
        .map(|v| v.trim().to_lowercase() == "acp")
        .unwrap_or(false)
}

fn acp_runtime_for_payload(payload: &Value, profile: &str) -> Option<String> {
    if let Some(runtime) = payload
        .get("acpRuntime")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
    {
        return Some(runtime.to_lowercase());
    }
    if is_acp_profile(profile) {
        return Some(default_acp_runtime_name());
    }
    if hermes_http_proxy_enabled() {
        None
    } else {
        Some(default_acp_runtime_name())
    }
}

fn acp_runtime_for_run(run_id: &str, profile: &str) -> Option<String> {
    if let Some(payload) = ACP_RUN_PAYLOADS
        .lock()
        .expect("acp run payloads")
        .get(run_id)
        .cloned()
    {
        return acp_runtime_for_payload(&payload, profile);
    }
    if ACP_ACTIVE_RUNS
        .lock()
        .expect("acp active runs")
        .contains_key(run_id)
    {
        return Some(crate::acp_bridge::runtime_name_for_profile(profile).to_string());
    }
    if is_acp_profile(profile) {
        return Some(crate::acp_bridge::runtime_name_for_profile(profile).to_string());
    }
    None
}

fn acp_run_payload_for_stream(run_id: &str) -> Option<Value> {
    ACP_RUN_PAYLOADS
        .lock()
        .expect("acp run payloads")
        .get(run_id)
        .cloned()
}

fn provider_default_key_env(provider: &str) -> Option<&'static str> {
    match provider.trim().to_ascii_lowercase().as_str() {
        "deepseek" => Some("DEEPSEEK_API_KEY"),
        "openrouter" => Some("OPENROUTER_API_KEY"),
        "omniroute" => Some("OMNIROUTE_API_KEY"),
        "openai" | "custom" => Some("OPENAI_API_KEY"),
        _ => None,
    }
}

fn acp_hermes_env_for_profile(profile: &str) -> Option<HashMap<String, String>> {
    let config = fs::read_to_string(profile_config_path(profile)).ok()?;
    let provider = yaml_path_value(&config, &["model", "provider"]).unwrap_or_default();
    let key_env = yaml_path_value(&config, &["model", "key_env"])
        .or_else(|| {
            if provider.is_empty() {
                None
            } else {
                yaml_path_value(&config, &["providers", &provider, "key_env"])
            }
        })
        .or_else(|| provider_default_key_env(&provider).map(str::to_string))?;
    let key = env_or_dotenv(&key_env)
        .or_else(|| yaml_path_value(&config, &["model", "api_key"]))
        .or_else(|| {
            if provider.is_empty() {
                None
            } else {
                yaml_path_value(&config, &["providers", &provider, "api_key"])
            }
        })
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())?;

    let mut env = HashMap::new();
    env.insert(key_env, key);
    Some(env)
}

fn merge_acp_runtime_env(
    base: Option<HashMap<String, String>>,
    extra: Option<HashMap<String, String>>,
) -> Option<HashMap<String, String>> {
    let mut merged = base.unwrap_or_default();
    if let Some(extra) = extra {
        merged.extend(extra);
    }
    if merged.is_empty() {
        None
    } else {
        Some(merged)
    }
}

fn local_ai_payload_permission_level(payload: &Value) -> &'static str {
    if let Some(grant) = local_share_grant_for_payload(payload).ok().flatten() {
        return grant.permission_level();
    }
    let raw = payload
        .get("permissionLevel")
        .or_else(|| payload.get("permission_level"))
        .and_then(Value::as_str)
        .map(str::trim)
        .unwrap_or("read_write")
        .to_ascii_lowercase();
    if raw == "shared_read" {
        "shared_read"
    } else if raw == "shared_write" {
        "shared_write"
    } else if raw == "read" || raw == "readonly" || raw == "read_only" {
        "read_only"
    } else {
        "read_write"
    }
}

fn local_ai_payload_is_read_only(payload: &Value) -> bool {
    matches!(
        local_ai_payload_permission_level(payload),
        "read_only" | "shared_read"
    )
}

fn local_share_grants_path() -> PathBuf {
    env::var(ENV_LOCAL_SHARE_GRANTS_FILE)
        .ok()
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from(LOCAL_SHARE_GRANTS_JSON))
}

fn local_resource_id_to_relative_path(resource_id: &str) -> Option<String> {
    let trimmed = resource_id.trim();
    let encoded = trimmed.strip_prefix("local-md:")?;
    let bytes = encoded.as_bytes();
    let mut decoded = Vec::with_capacity(bytes.len());
    let mut index = 0;
    while index < bytes.len() {
        if bytes[index] == b'~' {
            if index + 2 >= bytes.len() {
                return None;
            }
            let hex = &encoded[index + 1..index + 3];
            let byte = u8::from_str_radix(hex, 16).ok()?;
            decoded.push(byte);
            index += 3;
        } else {
            decoded.push(bytes[index]);
            index += 1;
        }
    }
    String::from_utf8(decoded).ok()
}

fn local_share_permission_allows_write(permission: &str, capabilities: &HashSet<String>) -> bool {
    let permission = permission.trim().to_ascii_lowercase();
    let has_ai = capabilities
        .iter()
        .any(|capability| capability.trim().eq_ignore_ascii_case("ai"));
    (permission == "write" || permission == "shared_write") && has_ai
}

fn local_share_grant_for_payload(payload: &Value) -> Result<Option<LocalShareGrant>, WebError> {
    let share_id = payload
        .get("shareId")
        .or_else(|| payload.get("share_id"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty());
    let Some(share_id) = share_id else {
        return Ok(None);
    };
    Ok(Some(load_local_share_grant_for_actor(
        share_id,
        payload.get("actorId").and_then(Value::as_str),
        payload.get("rootUri").and_then(Value::as_str),
    )?))
}

fn load_local_share_grant_for_actor(
    share_id: &str,
    actor_id: Option<&str>,
    root_uri: Option<&str>,
) -> Result<LocalShareGrant, WebError> {
    let path = local_share_grants_path();
    let content = fs::read_to_string(&path).map_err(|error| {
        WebError::new(
            StatusCode::FORBIDDEN,
            "local_share_grant_required",
            format!("共享 AI 会话需要 canonical share grant: {error}"),
        )
    })?;
    let payload: Value = serde_json::from_str(&content).map_err(|error| {
        WebError::bad_request_code(
            "local_share_grants_invalid",
            format!("share-grants.json 格式非法: {error}"),
        )
    })?;
    let grants = if let Some(array) = payload.as_array() {
        array
    } else {
        payload
            .get("grants")
            .and_then(Value::as_array)
            .ok_or_else(|| {
                WebError::bad_request_code(
                    "local_share_grants_invalid",
                    "share-grants.json 缺少 grants 数组",
                )
            })?
    };
    let Some(raw_grant) = grants.iter().find(|grant| {
        grant
            .get("shareId")
            .or_else(|| grant.get("share_id"))
            .and_then(Value::as_str)
            .map(str::trim)
            == Some(share_id)
    }) else {
        return Err(WebError::new(
            StatusCode::FORBIDDEN,
            "local_share_grant_not_found",
            "共享 AI 会话使用的 shareId 不存在",
        ));
    };
    let grant = parse_local_share_grant(raw_grant)?;
    if grant.revoked {
        return Err(WebError::new(
            StatusCode::FORBIDDEN,
            "local_share_grant_revoked",
            "共享 AI 会话使用的 share grant 已撤销",
        ));
    }
    if let (Some(target), Some(actor_id)) = (grant.target_user_id.as_deref(), actor_id) {
        if target.trim() != actor_id.trim() {
            return Err(WebError::new(
                StatusCode::FORBIDDEN,
                "local_share_grant_actor_mismatch",
                "当前用户不是 share grant 的 target 用户",
            ));
        }
    }
    if let (Some(grant_root), Some(root_uri)) = (grant.root_uri.as_deref(), root_uri) {
        if grant_root.trim() != root_uri.trim() {
            return Err(WebError::new(
                StatusCode::FORBIDDEN,
                "local_share_grant_root_mismatch",
                "请求 rootUri 与 canonical share grant 不一致",
            ));
        }
    }
    if !grant
        .capabilities
        .iter()
        .any(|capability| capability.trim().eq_ignore_ascii_case("ai"))
    {
        return Err(WebError::new(
            StatusCode::FORBIDDEN,
            "local_share_grant_ai_capability_required",
            "共享 AI 会话需要 share grant 包含 ai capability",
        ));
    }
    Ok(grant)
}

fn parse_local_share_grant(value: &Value) -> Result<LocalShareGrant, WebError> {
    let share_id = value
        .get("shareId")
        .or_else(|| value.get("share_id"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| {
            WebError::bad_request_code("local_share_grant_invalid", "share grant 缺少 shareId")
        })?
        .to_string();
    let owner_id = value
        .get("ownerId")
        .or_else(|| value.get("ownerUserId"))
        .or_else(|| value.get("owner_id"))
        .or_else(|| value.get("owner_user_id"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned);
    let target_user_id = value
        .get("targetUserId")
        .or_else(|| value.get("target_user_id"))
        .or_else(|| value.get("targetId"))
        .or_else(|| value.get("target_id"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned);
    let root_uri = value
        .get("rootUri")
        .or_else(|| value.get("root_uri"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned);
    let permission = value
        .get("permission")
        .or_else(|| value.get("permissionLevel"))
        .or_else(|| value.get("permission_level"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("read")
        .to_ascii_lowercase();
    let capabilities = value
        .get("capabilities")
        .and_then(Value::as_array)
        .map(|items| {
            items
                .iter()
                .filter_map(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .map(|value| value.to_ascii_lowercase())
                .collect::<HashSet<_>>()
        })
        .unwrap_or_default();
    let allowed_resource_ids = value
        .get("allowedResourceIds")
        .or_else(|| value.get("allowed_resource_ids"))
        .or_else(|| value.get("resourceIds"))
        .or_else(|| value.get("resource_ids"))
        .and_then(Value::as_array)
        .map(|items| {
            items
                .iter()
                .filter_map(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .map(ToOwned::to_owned)
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();
    let revoked = value
        .get("revoked")
        .and_then(Value::as_bool)
        .unwrap_or(false)
        || value
            .get("active")
            .and_then(Value::as_bool)
            .map(|active| !active)
            .unwrap_or(false)
        || value
            .get("revokedAt")
            .or_else(|| value.get("revoked_at"))
            .and_then(Value::as_str)
            .map(str::trim)
            .map(|revoked_at| !revoked_at.is_empty())
            .unwrap_or(false)
        || value
            .get("status")
            .and_then(Value::as_str)
            .map(str::trim)
            .map(|status| status.eq_ignore_ascii_case("revoked"))
            .unwrap_or(false);
    Ok(LocalShareGrant {
        share_id,
        owner_id,
        target_user_id,
        root_uri,
        permission,
        capabilities,
        allowed_resource_ids,
        revoked,
    })
}

fn acp_allowed_roots_env_for_payload(payload: &Value) -> Option<HashMap<String, String>> {
    if payload
        .get("sourceKind")
        .and_then(Value::as_str)
        .map(str::trim)
        != Some("local_folder")
    {
        return None;
    }
    let root_uri = payload
        .get("rootUri")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())?;
    let root_path = file_root_uri_to_permission_path(root_uri)?;
    let workspace_id = payload
        .get("workspaceId")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("default");
    let session_id = payload
        .get("sessionId")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("session");
    let document_id = payload
        .get("documentId")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("current");
    let actor_id = payload
        .get("actorId")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("anonymous");
    let share_grant = local_share_grant_for_payload(payload).ok().flatten();
    let permission_level = share_grant
        .as_ref()
        .map(LocalShareGrant::permission_level)
        .unwrap_or_else(|| local_ai_payload_permission_level(payload));
    let allowed_resource_ids = share_grant
        .as_ref()
        .map(|grant| grant.allowed_resource_ids.clone())
        .filter(|ids| !ids.is_empty())
        .unwrap_or_else(|| vec![document_id.to_string()]);
    let allowed_file_paths = share_grant
        .as_ref()
        .map(LocalShareGrant::allowed_file_paths)
        .unwrap_or_default();
    let mut access_scope = json!({
        "userId": actor_id,
        "workspaceId": workspace_id,
        "sessionId": session_id,
        "sourceKind": "local_folder",
        "permissionLevel": permission_level,
        "allowedRoots": [root_uri],
        "allowedFilePaths": allowed_file_paths,
        "allowedResourceIds": allowed_resource_ids
    });
    if let Some(grant) = share_grant.as_ref() {
        access_scope["shareContext"] = grant.share_context();
    }
    let mut env = HashMap::new();
    env.insert(
        "MNOTE_AI_ALLOWED_ROOTS_JSON".into(),
        serde_json::to_string(
            &share_grant
                .as_ref()
                .map(LocalShareGrant::allowed_file_paths)
                .filter(|paths| !paths.is_empty())
                .unwrap_or_else(|| vec![root_path.clone()]),
        )
        .ok()?,
    );
    env.insert("MNOTE_AI_WORKSPACE_ROOT".into(), root_path);
    env.insert(
        "MNOTE_AI_ACCESS_SCOPE_JSON".into(),
        serde_json::to_string(&access_scope).ok()?,
    );
    Some(env)
}

fn file_root_uri_to_permission_path(root_uri: &str) -> Option<String> {
    let trimmed = root_uri.trim();
    let path = trimmed.strip_prefix("file://").unwrap_or(trimmed).trim();
    if path.is_empty() || path == "/" {
        return None;
    }
    Some(path.to_string())
}

/// Returns the ACP runtime name for a profile.
/// For ACP profiles, returns the runtime backend name ("hermes" or "reasonix").
/// The profile name is used as the runtime name unless overridden by env var.
#[allow(dead_code)]
fn configured_runtime_for_profile(profile: &str) -> Option<String> {
    if !is_acp_profile(profile) {
        return None;
    }
    if profile == "reasonix" {
        return Some("reasonix".into());
    }
    let env_key = profile_env_key("MNOTE_WEB", profile, "RUNTIME_NAME");
    let name = env_or_dotenv(&env_key)
        .filter(|v| !v.trim().is_empty())
        .map(|v| v.trim().to_lowercase());
    Some(name.unwrap_or_else(|| profile.to_lowercase()))
}

fn configured_upstream_for_profile(profile: &str) -> Option<String> {
    let profile = profile.trim();
    if !profile.is_empty() && profile != "default" {
        let env_key = profile_env_key("MNOTE_WEB_HERMES", profile, "UPSTREAM_URL");
        if let Some(value) = env_or_dotenv(&env_key) {
            return Some(value.trim().trim_end_matches('/').to_string())
                .filter(|value| !value.is_empty());
        }
        let enabled = profile_config_value(profile, &["API_SERVER_ENABLED"])
            .map(|value| {
                matches!(
                    value.trim().to_ascii_lowercase().as_str(),
                    "true" | "1" | "yes"
                )
            })
            .unwrap_or(false);
        let port = profile_config_value(profile, &["API_SERVER_PORT"]);
        if enabled {
            if let Some(port) = port
                .as_deref()
                .map(str::trim)
                .filter(|value| !value.is_empty())
            {
                let host = profile_config_value(profile, &["API_SERVER_HOST"])
                    .unwrap_or_else(|| "127.0.0.1".into());
                return Some(format!("http://{}:{}", host.trim(), port));
            }
        }
    }
    configured_upstream()
}

fn configured_api_key() -> Option<String> {
    [
        "MNOTE_WEB_HERMES_API_KEY",
        "MNOTE_HERMES_API_KEY",
        "HERMES_API_SERVER_KEY",
        "API_SERVER_KEY",
    ]
    .into_iter()
    .find_map(env_or_dotenv)
    .map(|value| value.trim().to_string())
    .filter(|value| !value.is_empty())
}

fn configured_api_key_for_profile(profile: &str) -> Option<String> {
    let profile = profile.trim();
    if !profile.is_empty() && profile != "default" {
        let env_key = profile_env_key("MNOTE_WEB_HERMES", profile, "API_KEY");
        if let Some(value) = env_or_dotenv(&env_key) {
            return Some(value);
        }
        if let Some(value) = profile_config_value(profile, &["API_SERVER_KEY"]) {
            let trimmed = value.trim().to_string();
            if !trimmed.is_empty() {
                return Some(trimmed);
            }
        }
    }
    configured_api_key()
}

#[derive(Debug)]
struct GatewayProbe {
    ok: bool,
    status: String,
    http_status: Option<u64>,
    path: Option<String>,
    message: Option<String>,
}

async fn probe_gateway_health(upstream: &str, api_key: Option<String>) -> GatewayProbe {
    let client = match reqwest::Client::builder()
        .timeout(Duration::from_secs(3))
        .build()
    {
        Ok(client) => client,
        Err(error) => {
            return GatewayProbe {
                ok: false,
                status: "client_error".into(),
                http_status: None,
                path: None,
                message: Some(format!("Hermes gateway health client 构造失败: {error}")),
            };
        }
    };
    for path in ["/health", "/v1/models"] {
        let Ok(url) = upstream_url(upstream, path) else {
            continue;
        };
        let mut request = client.get(url);
        if let Some(api_key) = api_key.as_deref() {
            request = request.bearer_auth(api_key);
        }
        match request.send().await {
            Ok(response) if response.status().is_success() => {
                return GatewayProbe {
                    ok: true,
                    status: "ok".into(),
                    http_status: Some(response.status().as_u16() as u64),
                    path: Some(path.into()),
                    message: None,
                };
            }
            Ok(response) => {
                let status = response.status();
                let text = response.text().await.unwrap_or_default();
                if path == "/v1/models" || status != reqwest::StatusCode::NOT_FOUND {
                    return GatewayProbe {
                        ok: false,
                        status: "upstream_error".into(),
                        http_status: Some(status.as_u16() as u64),
                        path: Some(path.into()),
                        message: Some(text.chars().take(600).collect()),
                    };
                }
            }
            Err(error) => {
                return GatewayProbe {
                    ok: false,
                    status: "unreachable".into(),
                    http_status: None,
                    path: Some(path.into()),
                    message: Some(error.to_string()),
                };
            }
        }
    }
    GatewayProbe {
        ok: false,
        status: "unknown".into(),
        http_status: None,
        path: None,
        message: Some("Hermes gateway health 探测未返回有效结果".into()),
    }
}

fn env_or_dotenv(key: &str) -> Option<String> {
    if let Ok(value) = std::env::var(key) {
        let trimmed = value.trim().trim_matches('"').to_string();
        if !trimmed.is_empty() {
            return Some(trimmed);
        }
    }
    if cfg!(test) {
        return None;
    }
    let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("../../..")
        .join(".env.all");
    let content = fs::read_to_string(root).ok()?;
    for line in content.lines() {
        let line = line.trim_end_matches('\r');
        if line.starts_with('#') || line.trim().is_empty() {
            continue;
        }
        let Some((candidate_key, value)) = line.split_once('=') else {
            continue;
        };
        if candidate_key.trim() != key {
            continue;
        }
        let trimmed = value.trim().trim_matches('"').to_string();
        if !trimmed.is_empty() {
            return Some(trimmed);
        }
    }
    None
}

fn yaml_path_value(content: &str, path: &[&str]) -> Option<String> {
    let mut stack: Vec<(usize, String)> = Vec::new();
    for raw_line in content.lines() {
        let line = raw_line.trim_end_matches('\r');
        let trimmed = line.trim();
        if trimmed.is_empty() || trimmed.starts_with('#') || !line.contains(':') {
            continue;
        }
        let indent = line.chars().take_while(|ch| ch.is_whitespace()).count();
        while stack
            .last()
            .map(|(level, _)| *level >= indent)
            .unwrap_or(false)
        {
            stack.pop();
        }
        let Some((key, value)) = trimmed.split_once(':') else {
            continue;
        };
        let key = key.trim().trim_matches('"').trim_matches('\'').to_string();
        let value = value
            .trim()
            .trim_matches('"')
            .trim_matches('\'')
            .to_string();
        stack.push((indent, key));
        let keys = stack
            .iter()
            .map(|(_, key)| key.as_str())
            .collect::<Vec<_>>();
        if keys == path && !value.is_empty() {
            return Some(value);
        }
    }
    None
}

fn profile_gateway_status(profile: &str) -> Value {
    let normalized_profile = if profile.trim().is_empty() {
        "default"
    } else {
        profile.trim()
    };
    let config_path = profile_config_path(normalized_profile);
    let config = fs::read_to_string(&config_path).unwrap_or_default();
    let model_default = yaml_path_value(&config, &["model", "default"]).unwrap_or_default();
    let provider = yaml_path_value(&config, &["model", "provider"]).unwrap_or_default();
    let gateway = yaml_path_value(&config, &["model", "gateway"])
        .or_else(|| yaml_path_value(&config, &["model", "base_url"]))
        .unwrap_or_default();
    let model_api_key = yaml_path_value(&config, &["model", "api_key"]);
    let model_key_env = yaml_path_value(&config, &["model", "key_env"]);
    let provider_api_key = if provider.is_empty() {
        None
    } else {
        yaml_path_value(&config, &["providers", &provider, "api_key"])
    };
    let provider_key_env = if provider.is_empty() {
        None
    } else {
        yaml_path_value(&config, &["providers", &provider, "key_env"])
    };
    let key_env_configured = model_key_env
        .as_deref()
        .or(provider_key_env.as_deref())
        .and_then(env_or_dotenv)
        .is_some();
    let api_key_configured = model_api_key
        .as_deref()
        .or(provider_api_key.as_deref())
        .map(|value| !value.trim().is_empty())
        .unwrap_or(false)
        || key_env_configured;
    let mut suggestions = Vec::new();
    if model_default.is_empty() {
        suggestions
            .push("当前 Hermes profile 缺少 model.default，请在 Hermes 设置中选择默认模型。");
    }
    if provider.is_empty() {
        suggestions
            .push("当前 Hermes profile 缺少 model.provider，请在 Hermes 设置中选择 provider。");
    }
    if !api_key_configured {
        suggestions.push("当前 Hermes profile 未检测到 provider API key 或 key_env，请在 Hermes 设置中补齐 API key。");
    }
    json!({
        "name": normalized_profile,
        "path": profile_home(normalized_profile).to_string_lossy(),
        "configPath": config_path.to_string_lossy(),
        "configExists": config_path.exists(),
        "modelDefault": model_default,
        "provider": provider,
        "gateway": gateway,
        "modelConfigured": !model_default.is_empty(),
        "apiKeyConfigured": api_key_configured,
        "suggestions": suggestions
    })
}

fn unique_string_values(values: Vec<Value>) -> Vec<Value> {
    let mut seen = HashSet::new();
    values
        .into_iter()
        .filter_map(|value| value.as_str().map(str::trim).map(ToOwned::to_owned))
        .filter(|value| !value.is_empty())
        .filter(|value| seen.insert(value.clone()))
        .map(Value::String)
        .collect()
}

fn hermes_settings_suggestions(status: Option<u64>, message: Option<&str>) -> Vec<String> {
    let lower = message.unwrap_or_default().to_lowercase();
    let mut suggestions = Vec::new();
    if lower.contains("model.default")
        || (lower.contains("model") && (lower.contains("default") || lower.contains("missing")))
    {
        suggestions.push(
            "当前 Hermes profile 可能缺少 model.default，请在 Hermes 设置中选择默认模型。".into(),
        );
    }
    if lower.contains("api key")
        || lower.contains("apikey")
        || lower.contains("unauthorized")
        || lower.contains("forbidden")
        || matches!(status, Some(401 | 403))
    {
        suggestions.push("当前 Hermes provider 可能缺少 API key 或 key_env 未生效，请在 Hermes 设置中补齐 API key。".into());
    }
    if lower.contains("profile") || lower.contains("provider") {
        suggestions
            .push("请检查当前页面 AI 选择的 Hermes profile、provider 与 gateway 是否匹配。".into());
    }
    if suggestions.is_empty() {
        suggestions.push("请检查 Hermes gateway 是否正在运行，并确认当前 profile 的 model.default、provider 与 API key 已配置。".into());
    }
    suggestions
}

fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<Value, WebError> {
    let message = extract_run_message(&payload).ok_or_else(|| {
        WebError::bad_request_code("hermes_client_bad_request", "缺少 message")
            .with_context(context)
            .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
            .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
    })?;
    let document_id = payload
        .get("documentId")
        .and_then(Value::as_str)
        .filter(|value| !value.trim().is_empty())
        .unwrap_or("current");
    let trace_id = payload
        .get("traceId")
        .and_then(Value::as_str)
        .filter(|value| !value.trim().is_empty())
        .unwrap_or(&context.trace.trace_id);
    let session_id = payload
        .get("sessionId")
        .and_then(Value::as_str)
        .filter(|value| !value.trim().is_empty())
        .map(ToOwned::to_owned)
        .unwrap_or_else(|| stable_session_id(document_id, trace_id));

    let page_context = payload.get("pageContext").cloned().unwrap_or(Value::Null);
    let workspace_id = payload.get("workspaceId").cloned().unwrap_or(Value::Null);
    let workspace_id_string = payload
        .get("workspaceId")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
        .or_else(|| context.workspace.workspace_id.clone())
        .unwrap_or_else(|| "default".into());
    let source_kind = payload
        .get("sourceKind")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("convex_workspace");
    let root_uri = payload
        .get("rootUri")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty());
    let is_local_source = source_kind == "local_folder";
    let profile = payload
        .get("profile")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty());
    let tool_guidance = concat!(
        "每次调用 mnote 工具都必须显式透传 workspaceId、documentId、actorId、sessionId、runId、traceId；",
        "actorId 必须使用本 instructions 中的 actorId，不允许省略或填 hermes/anonymous。",
        "凡用户要求新增、删除、修改、替换、移动正文段落或块，必须走块级链路。",
        "本 instructions 的 pageContext.aiContext 是本次 run 开始时冻结的 mnote.page_ai_context.v1，",
        "其中 contextBlocks/pageXml/pageText 可直接作为 tiptapRead 风格的已读上下文使用；",
        "普通正文 search/replace、局部段落替换或全文 markdown 替换，应优先调用 mnote_doc_markdown_edit，",
        "使用 pageText 或 contextBlocks.text 构造 search/replace，写入后必须回读验证。",
        "如果 aiContext 已包含足够唯一的目标文本，应直接调用 mnote_doc_markdown_edit，",
        "不要为了同一上下文再先调用 mnote_doc_fetch。",
        "scope=selection 时只能修改 allowedTargetBlockIds 内的块；",
        "mnote_doc_apply_block_ops 仅用于 markdown_edit 无法表达的结构性块操作，例如块移动、资源块、复杂子块或必须按 blockId 精确处理的场景；",
        "该结构性工具可用唯一 matchText/anchorText 或已知 blockId/anchorBlockId 定位，",
        "避免 fetch、plan、多个单步写入造成多轮模型往返。",
        "只有当文本不唯一、目标不明确、涉及复杂块/子块/表格/资源块，或 markdown_edit 返回歧义/不支持时，",
        "再降级为 mnote_doc_fetch(scope=full 或 keyword, detail=with_ids) 定位块，",
        "mnote_block_fetch 读取目标块 revisionRef/context，",
        "mnote_doc_plan_update(dryRun=true) 生成并检查 diff，",
        "最后调用 mnote_block_replace、mnote_block_insert_after、mnote_block_delete 或 mnote_block_move_after。",
        "写入后再 mnote_doc_fetch 或 mnote_block_fetch 回读验证。",
        "mnote_page_get 只用于读取页面标题、页面设置或粗略摘要；",
        "mnote_page_save 只允许在用户明确要求整页覆盖/整页追加且块级工具无法表达时作为高风险兜底，",
        "不能作为正文块新增、删除、修改、移动的首选工具。",
        "不要依据非 aiContext 的页面摘要猜测正文内容或块 id。"
    );
    let actor_id = payload
        .get("actorId")
        .and_then(Value::as_str)
        .unwrap_or(&context.auth.actor_id);
    let actor_type = payload
        .get("actorType")
        .and_then(Value::as_str)
        .unwrap_or(&context.auth.actor_type);
    let share_grant = if is_local_source {
        local_share_grant_for_payload(&payload)?
    } else {
        None
    };
    let local_file_reference = if is_local_source {
        root_uri.map(|root_uri| {
            json!({
                "sourceKind": source_kind,
                "workspaceId": workspace_id_string,
                "documentId": document_id,
                "rootUri": root_uri,
                "selection": {
                    "selectedBlockId": payload.get("selectedBlockId").cloned().unwrap_or(Value::Null),
                    "selectedText": payload.get("selectedText").cloned().unwrap_or(Value::Null)
                }
            })
        })
    } else {
        None
    };
    let local_ai_access_scope = if is_local_source {
        root_uri.map(|root_uri| {
            let permission_level = share_grant
                .as_ref()
                .map(LocalShareGrant::permission_level)
                .unwrap_or_else(|| local_ai_payload_permission_level(&payload));
            let allowed_resource_ids = share_grant
                .as_ref()
                .map(|grant| grant.allowed_resource_ids.clone())
                .filter(|ids| !ids.is_empty())
                .unwrap_or_else(|| vec![document_id.to_string()]);
            let allowed_file_paths = share_grant
                .as_ref()
                .map(LocalShareGrant::allowed_file_paths)
                .unwrap_or_default();
            let mut scope = json!({
                "userId": actor_id,
                "workspaceId": workspace_id_string,
                "sessionId": session_id,
                "sourceKind": "local_folder",
                "permissionLevel": permission_level,
                "allowedRoots": [root_uri],
                "allowedFilePaths": allowed_file_paths,
                "allowedResourceIds": allowed_resource_ids
            });
            if let Some(grant) = share_grant.as_ref() {
                scope["shareContext"] = grant.share_context();
            }
            scope
        })
    } else {
        None
    };
    let sanitized_page_context = if is_local_source {
        sanitize_run_local_page_context(page_context)
    } else {
        sanitize_run_page_context(page_context)
    };
    let instructions = json!({
        "role": "mnote_page_ai_context",
        "workspaceId": workspace_id,
        "documentId": document_id,
        "sourceKind": source_kind,
        "rootUri": if is_local_source { root_uri.map(Value::from).unwrap_or(Value::Null) } else { Value::Null },
        "profile": profile.unwrap_or("default"),
        "actorId": actor_id,
        "actorType": actor_type,
        "sessionId": session_id,
        "runId": payload
            .get("runId")
            .and_then(Value::as_str)
            .filter(|value| !value.trim().is_empty())
            .unwrap_or(&session_id),
        "traceId": trace_id,
        "toolGuidance": tool_guidance,
        "fileReference": local_file_reference.unwrap_or(Value::Null),
        "aiAccessScope": local_ai_access_scope.unwrap_or(Value::Null),
        "blockEditingToolOrder": [
            "mnote_doc_markdown_edit for plain body search/replace or full markdown replacement",
            "mnote_doc_fetch | mnote_doc_find only when target text is ambiguous or structure is complex",
            "mnote_doc_apply_block_ops only for structural block operations that markdown_edit cannot express",
            "mnote_doc_fetch",
            "mnote_block_fetch",
            "mnote_doc_plan_update(dryRun=true)",
            "mnote_block_replace | mnote_block_insert_after | mnote_block_delete | mnote_block_move_after",
            "mnote_doc_fetch | mnote_block_fetch"
        ],
        "pageSavePolicy": {
            "mnote_page_save": "fallback_only_for_explicit_whole_page_write",
            "forBlockEditing": "forbidden_as_first_choice"
        },
        "selectedBlockId": payload.get("selectedBlockId").cloned().unwrap_or(Value::Null),
        "selectedText": payload.get("selectedText").cloned().unwrap_or(Value::Null),
        "pageContext": sanitized_page_context
    })
    .to_string();

    let mut body = json!({
        "input": message,
        "session_id": session_id,
        "instructions": instructions
    });
    if let Some(model) = payload
        .get("model")
        .and_then(Value::as_str)
        .filter(|value| !value.trim().is_empty())
    {
        body["model"] = Value::String(model.to_string());
    }
    if let Some(profile) = profile {
        body["profile"] = Value::String(profile.to_string());
    }
    Ok(body)
}

fn effective_run_actor(state: &AppState, context: &RequestContext) -> (String, String) {
    let actor_id = context.auth.actor_id.trim();
    if !actor_id.is_empty() && actor_id != "anonymous" {
        return (actor_id.to_string(), context.auth.actor_type.clone());
    }
    if context.auth.authorization.is_some() || context.auth.cookie_header.is_some() {
        return (state.config().dev_user_id.clone(), "devFallback".into());
    }
    ("anonymous".into(), "anonymous".into())
}

async fn resolve_run_actor(state: &AppState, context: &RequestContext) -> (String, String) {
    let actor_id = context.auth.actor_id.trim();
    if !actor_id.is_empty() && actor_id != "anonymous" {
        return (actor_id.to_string(), context.auth.actor_type.clone());
    }
    if context.auth.authorization.is_none() && context.auth.cookie_header.is_none() {
        return ("anonymous".into(), "anonymous".into());
    }
    if let Ok(Some(user_id)) = resolve_current_convex_user_id(state, context).await {
        return (user_id, "user".into());
    }
    effective_run_actor(state, context)
}

async fn resolve_current_convex_user_id(
    state: &AppState,
    context: &RequestContext,
) -> Result<Option<String>, WebError> {
    let payload = execute_convex_query_by_name(
        state.config(),
        context,
        "users:currentUser",
        json!({}),
        None,
        "hermes_run_current_user",
    )
    .await?;
    Ok(payload
        .get("_id")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned))
}

async fn effective_session_store_user_id(
    state: &AppState,
    context: &RequestContext,
) -> Result<String, WebError> {
    let actor_id = context.auth.actor_id.trim();
    if !actor_id.is_empty() && actor_id != "anonymous" {
        return Ok(actor_id.to_string());
    }
    if let Some(user_id) = resolve_current_convex_user_id(state, context).await? {
        return Ok(user_id);
    }
    Err(WebError::new(
        StatusCode::UNAUTHORIZED,
        "hermes_client_unauthorized",
        "页面 AI session store 需要登录后访问",
    )
    .with_context(context)
    .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
    .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client"))
}

fn stamp_run_actor(payload: &mut Value, actor_id: &str, actor_type: &str) {
    let Value::Object(map) = payload else {
        return;
    };
    map.insert("actorId".into(), Value::String(actor_id.to_string()));
    map.insert("actorType".into(), Value::String(actor_type.to_string()));
}

fn sanitize_run_page_context(page_context: Value) -> Value {
    let Some(source) = page_context.as_object() else {
        return Value::Null;
    };
    let mut sanitized = serde_json::Map::new();
    for key in [
        "contextScope",
        "node",
        "pageSubtreeSource",
        "evidence",
        "pageOptions",
        "contentAccess",
        "aiContext",
    ] {
        if let Some(value) = source.get(key) {
            sanitized.insert(key.to_string(), value.clone());
        }
    }
    sanitized.insert(
        "contentAccess".to_string(),
        sanitized
            .get("contentAccess")
            .cloned()
            .unwrap_or_else(|| Value::String("mnote.doc.fetch".into())),
    );
    Value::Object(sanitized)
}

fn sanitize_run_local_page_context(page_context: Value) -> Value {
    let Some(source) = page_context.as_object() else {
        return Value::Null;
    };
    let mut sanitized = serde_json::Map::new();
    for key in ["contextScope", "node", "evidence"] {
        if let Some(value) = source.get(key) {
            sanitized.insert(key.to_string(), value.clone());
        }
    }
    sanitized.insert(
        "contentAccess".to_string(),
        Value::String("file.reference".into()),
    );
    if let Some(ai_context) = source.get("aiContext").and_then(Value::as_object) {
        let mut slim_ai_context = serde_json::Map::new();
        for key in [
            "schema",
            "scope",
            "selectedBlockIds",
            "allowedTargetBlockIds",
        ] {
            if let Some(value) = ai_context.get(key) {
                slim_ai_context.insert(key.to_string(), value.clone());
            }
        }
        if !slim_ai_context.is_empty() {
            sanitized.insert("aiContext".to_string(), Value::Object(slim_ai_context));
        }
    }
    Value::Object(sanitized)
}

fn now_ms() -> u128 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_millis())
        .unwrap_or_default()
}

fn runtime_state_to_json(state: &HermesRuntimeState) -> Value {
    json!({
        "sessionId": state.session_id,
        "runId": state.run_id,
        "profile": state.profile,
        "documentId": state.document_id,
        "traceId": state.trace_id,
        "status": state.status,
        "startedAt": state.started_at,
        "lastEventAt": state.last_event_at,
        "lastEvent": state.last_event,
        "lastToolName": state.last_tool_name,
        "lastToolCallId": state.last_tool_call_id,
        "lastAuditId": state.last_audit_id
    })
}

fn runtime_state_for_session(session_id: &str) -> Value {
    let registry = HERMES_RUNTIME_REGISTRY
        .lock()
        .expect("hermes runtime registry");
    registry
        .get(session_id)
        .map(runtime_state_to_json)
        .unwrap_or(Value::Null)
}

fn runtime_state_for_run(run_id: &str) -> Option<Value> {
    let registry = HERMES_RUNTIME_REGISTRY
        .lock()
        .expect("hermes runtime registry");
    registry
        .values()
        .find(|state| state.run_id == run_id)
        .map(runtime_state_to_json)
}

fn runtime_status_for_run(run_id: &str) -> Option<String> {
    let registry = HERMES_RUNTIME_REGISTRY
        .lock()
        .expect("hermes runtime registry");
    registry
        .values()
        .find(|state| state.run_id == run_id)
        .map(|state| state.status.clone())
}

fn run_registration_from_payload(
    context: &RequestContext,
    payload: &Value,
) -> HermesRunRegistration {
    let document_id = payload
        .get("documentId")
        .and_then(Value::as_str)
        .filter(|value| !value.trim().is_empty())
        .unwrap_or("current")
        .to_string();
    let trace_id = payload
        .get("traceId")
        .and_then(Value::as_str)
        .filter(|value| !value.trim().is_empty())
        .unwrap_or(&context.trace.trace_id)
        .to_string();
    let session_id = payload
        .get("sessionId")
        .and_then(Value::as_str)
        .filter(|value| !value.trim().is_empty())
        .map(ToOwned::to_owned)
        .unwrap_or_else(|| stable_session_id(&document_id, &trace_id));
    let profile = payload
        .get("profile")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("default")
        .to_string();
    HermesRunRegistration {
        session_id,
        profile,
        document_id,
        trace_id,
    }
}

/// Register a runtime state from a registration (without upstream response).
/// Used by the ACP path where no Hermes HTTP upstream exists.
fn new_acp_run_id(registration: &HermesRunRegistration) -> String {
    format!("run_{}_{}", registration.trace_id, now_ms())
}

fn register_acp_runtime(registration: &HermesRunRegistration, run_id: &str) -> Value {
    let now = now_ms();
    let state = HermesRuntimeState {
        session_id: registration.session_id.clone(),
        run_id: run_id.to_string(),
        profile: registration.profile.clone(),
        document_id: registration.document_id.clone(),
        trace_id: registration.trace_id.clone(),
        status: "acp_pending".into(),
        started_at: now,
        last_event_at: now,
        last_event: Some("run.started".into()),
        last_tool_name: None,
        last_tool_call_id: None,
        last_audit_id: None,
    };
    let json = runtime_state_to_json(&state);
    HERMES_RUNTIME_REGISTRY
        .lock()
        .expect("hermes runtime registry")
        .insert(registration.session_id.clone(), state);
    json
}

fn acp_runtime_run_store_args(
    context: &RequestContext,
    registration: &HermesRunRegistration,
    run_id: &str,
    acp_runtime: &str,
    runtime_state: &Value,
    payload: &Value,
) -> Value {
    let workspace_id = payload
        .get("workspaceId")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
        .or_else(|| context.workspace.workspace_id.clone());
    json!({
        "schema": "mnote.acp_runtime_run.v1",
        "source": "acp",
        "userId": runtime_store_user_id(context, payload),
        "workspaceId": workspace_id,
        "documentId": registration.document_id,
        "sessionId": registration.session_id,
        "runId": run_id,
        "title": payload
            .get("title")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty()),
        "profile": registration.profile,
        "acpRuntime": acp_runtime,
        "traceId": registration.trace_id,
        "status": runtime_state
            .get("status")
            .and_then(Value::as_str)
            .unwrap_or("acp_pending"),
        "runtime": runtime_state,
        "payload": payload,
        "retention": {
            "kind": "ttl",
            "ttlMs": 7 * 24 * 60 * 60 * 1000i64
        },
        "createdAt": now_ms(),
        "updatedAt": now_ms()
    })
}

fn json_string(value: &Value) -> String {
    serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string())
}

fn ai_runtime_run_to_json(record: &control_plane::AiRuntimeRunRecord) -> Value {
    let runtime = serde_json::from_str::<Value>(&record.runtime_json).unwrap_or(Value::Null);
    let payload = serde_json::from_str::<Value>(&record.payload_json).unwrap_or(Value::Null);
    json!({
        "sessionId": record.session_id,
        "runId": record.run_id,
        "workspaceId": record.workspace_id,
        "documentId": record.document_id,
        "title": record.title,
        "profile": record.profile,
        "acpRuntime": record.acp_runtime,
        "traceId": record.trace_id,
        "status": record.status,
        "runtime": runtime,
        "payload": payload,
        "createdAt": record.created_at,
        "updatedAt": record.updated_at,
        "persistence": ACP_RUNTIME_SQLITE_STORE,
    })
}

fn ai_runtime_event_to_json(record: &control_plane::AiRuntimeEventRecord) -> Value {
    let payload = serde_json::from_str::<Value>(&record.payload_json).unwrap_or(Value::Null);
    json!({
        "eventId": record.id,
        "sessionId": record.session_id,
        "runId": record.run_id,
        "workspaceId": record.workspace_id,
        "documentId": record.document_id,
        "profile": record.profile,
        "acpRuntime": record.acp_runtime,
        "eventType": record.event_type,
        "payload": payload,
        "createdAt": record.created_at,
        "persistence": ACP_RUNTIME_SQLITE_STORE,
    })
}

fn query_bool(query: &HashMap<String, String>, key: &str) -> bool {
    query
        .get(key)
        .map(String::as_str)
        .map(str::trim)
        .map(|value| matches!(value, "1" | "true" | "TRUE" | "yes" | "YES"))
        .unwrap_or(false)
}

fn use_legacy_convex_acp_store(query: &HashMap<String, String>) -> bool {
    query_bool(query, "legacyConvex") || query_bool(query, "convex")
}

fn local_session_share_id(payload: &Value) -> Option<String> {
    payload
        .get("shareId")
        .or_else(|| payload.get("share_id"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
}

fn runtime_store_user_id(context: &RequestContext, payload: &Value) -> String {
    payload
        .get("actorId")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty() && *value != "anonymous")
        .map(ToOwned::to_owned)
        .unwrap_or_else(|| context.auth.actor_id.clone())
}

fn local_ai_session_root_dir(root_uri: &str) -> Result<PathBuf, WebError> {
    let root_path = if let Some(stripped) = root_uri.trim().strip_prefix("file://") {
        stripped.trim()
    } else {
        root_uri.trim()
    };
    if root_path.is_empty() {
        return Err(WebError::bad_request_code(
            "local_ai_session_root_required",
            "缺少本地会话 rootUri",
        ));
    }
    Ok(PathBuf::from(root_path))
}

fn local_agent_audit_root_dir() -> PathBuf {
    env::var("MNOTE_LOCAL_AGENT_AUDIT_DIR")
        .ok()
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from(LOCAL_AGENT_AUDIT_DIR))
}

fn local_agent_audit_path() -> PathBuf {
    local_agent_audit_root_dir().join(LOCAL_AGENT_AUDIT_JSONL)
}

fn local_agent_audit_should_skip(path: &FsPath) -> bool {
    matches!(
        path.file_name()
            .and_then(|value| value.to_str())
            .map(str::trim),
        Some(".mnote") | Some("ai-sessions")
    )
}

fn local_agent_audit_snapshot_entry(
    path: &FsPath,
) -> Result<LocalAgentAuditFileSnapshot, WebError> {
    let metadata = fs::metadata(path).map_err(|error| {
        WebError::bad_request_code(
            "local_ai_audit_snapshot_failed",
            format!("无法读取本地文件快照: {error}"),
        )
    })?;
    let modified_ms = metadata
        .modified()
        .ok()
        .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
        .map(|duration| duration.as_millis())
        .unwrap_or_default();
    let size = metadata.len();
    let bytes = fs::read(path).map_err(|error| {
        WebError::bad_request_code(
            "local_ai_audit_snapshot_failed",
            format!("无法读取本地文件快照内容: {error}"),
        )
    })?;
    let hash = {
        let mut hasher = std::collections::hash_map::DefaultHasher::new();
        bytes.hash(&mut hasher);
        hasher.finish()
    };
    let markdown_content = if path.extension().and_then(|value| value.to_str()) == Some("md") {
        String::from_utf8(bytes).ok()
    } else {
        None
    };
    Ok(LocalAgentAuditFileSnapshot {
        size,
        modified_ms,
        hash,
        markdown_content,
    })
}

fn local_agent_audit_collect_snapshot(root_uri: &str) -> Result<LocalAgentAuditSnapshot, WebError> {
    let root = local_ai_session_root_dir(root_uri)?;
    let canonical_root = root.canonicalize().map_err(|error| {
        WebError::bad_request_code(
            "local_folder_unavailable",
            format!("无法访问本地文件夹: {error}"),
        )
    })?;
    let mut files = BTreeMap::new();
    let mut stack = vec![canonical_root.clone()];
    while let Some(dir) = stack.pop() {
        for entry in fs::read_dir(&dir).map_err(|error| {
            WebError::bad_request_code(
                "local_ai_audit_snapshot_failed",
                format!("读取本地文件夹失败: {error}"),
            )
        })? {
            let entry = entry.map_err(|error| {
                WebError::bad_request_code(
                    "local_ai_audit_snapshot_failed",
                    format!("读取本地文件夹失败: {error}"),
                )
            })?;
            let path = entry.path();
            if local_agent_audit_should_skip(&path) {
                continue;
            }
            let metadata = entry.metadata().map_err(|error| {
                WebError::bad_request_code(
                    "local_ai_audit_snapshot_failed",
                    format!("读取本地文件夹失败: {error}"),
                )
            })?;
            if metadata.is_dir() {
                stack.push(path);
                continue;
            }
            if !metadata.is_file() {
                continue;
            }
            let relative_path = path
                .strip_prefix(&canonical_root)
                .unwrap_or(&path)
                .to_string_lossy()
                .replace('\\', "/");
            if relative_path.is_empty() {
                continue;
            }
            files.insert(relative_path, local_agent_audit_snapshot_entry(&path)?);
        }
    }
    Ok(LocalAgentAuditSnapshot {
        root_uri: root_uri.to_string(),
        files,
    })
}

fn local_agent_audit_diff_summary(
    before: Option<&LocalAgentAuditFileSnapshot>,
    after: Option<&LocalAgentAuditFileSnapshot>,
) -> String {
    match (before, after) {
        (None, Some(after)) => format!("新增文件 size={} hash={}", after.size, after.hash),
        (Some(before), None) => format!("删除文件 size={} hash={}", before.size, before.hash),
        (Some(before), Some(after)) => {
            if before.hash == after.hash && before.size == after.size {
                "内容无变化".into()
            } else {
                let before_lines = before
                    .markdown_content
                    .as_deref()
                    .map(|text| text.lines().count())
                    .unwrap_or_default();
                let after_lines = after
                    .markdown_content
                    .as_deref()
                    .map(|text| text.lines().count())
                    .unwrap_or_default();
                format!(
                    "修改文件 size:{}→{} lines:{}→{} hash:{}→{}",
                    before.size, after.size, before_lines, after_lines, before.hash, after.hash
                )
            }
        }
        (None, None) => String::new(),
    }
}

fn local_agent_audit_change_files(
    before: &LocalAgentAuditSnapshot,
    after: &LocalAgentAuditSnapshot,
) -> Value {
    let mut paths = BTreeMap::new();
    for path in before.files.keys().chain(after.files.keys()) {
        paths.insert(path.clone(), ());
    }
    let changed = paths
        .into_keys()
        .filter_map(|path| {
            let before_entry = before.files.get(&path);
            let after_entry = after.files.get(&path);
            if matches!((before_entry, after_entry), (Some(before), Some(after)) if before.hash == after.hash && before.size == after.size) {
                return None;
            }
            Some(json!({
                "path": path,
                "changeType": match (before_entry, after_entry) {
                    (None, Some(_)) => "added",
                    (Some(_), None) => "deleted",
                    _ => "modified",
                },
                "summary": local_agent_audit_diff_summary(before_entry, after_entry),
                "sizeBefore": before_entry.map(|entry| entry.size).unwrap_or(0),
                "sizeAfter": after_entry.map(|entry| entry.size).unwrap_or(0),
                "modifiedBeforeMs": before_entry.map(|entry| entry.modified_ms).unwrap_or_default(),
                "modifiedAfterMs": after_entry.map(|entry| entry.modified_ms).unwrap_or_default(),
                "hashBefore": before_entry.map(|entry| entry.hash).unwrap_or_default(),
                "hashAfter": after_entry.map(|entry| entry.hash).unwrap_or_default(),
            }))
        })
        .collect::<Vec<_>>();
    Value::Array(changed)
}

fn local_agent_audit_write_event(event: &Value) -> Result<(), WebError> {
    let audit_dir = local_agent_audit_root_dir();
    fs::create_dir_all(&audit_dir).map_err(|error| {
        WebError::bad_request_code(
            "local_ai_audit_write_failed",
            format!("无法创建本地审计目录: {error}"),
        )
    })?;
    let path = local_agent_audit_path();
    let mut file = fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&path)
        .map_err(|error| {
            WebError::bad_request_code(
                "local_ai_audit_write_failed",
                format!("无法打开本地审计文件: {error}"),
            )
        })?;
    let line = serde_json::to_string(event).map_err(|error| {
        WebError::bad_request_code(
            "local_ai_audit_write_failed",
            format!("无法序列化本地审计事件: {error}"),
        )
    })?;
    writeln!(file, "{line}").map_err(|error| {
        WebError::bad_request_code(
            "local_ai_audit_write_failed",
            format!("无法写入本地审计事件: {error}"),
        )
    })
}

fn local_agent_audit_store_snapshot(run_id: &str, snapshot: LocalAgentAuditSnapshot) {
    ACP_LOCAL_AUDIT_SNAPSHOTS
        .lock()
        .expect("acp local audit snapshots")
        .insert(run_id.to_string(), snapshot);
}

fn local_agent_audit_take_snapshot(run_id: &str) -> Option<LocalAgentAuditSnapshot> {
    ACP_LOCAL_AUDIT_SNAPSHOTS
        .lock()
        .expect("acp local audit snapshots")
        .remove(run_id)
}

fn local_agent_audit_event(
    context: &RequestContext,
    payload: &Value,
    run_id: &str,
    acp_runtime: &str,
    status: &str,
    changed_files: Value,
    source_snapshot: Option<&LocalAgentAuditSnapshot>,
    _target_snapshot: Option<&LocalAgentAuditSnapshot>,
    write_attempt_rejected: bool,
) -> Value {
    let root_uri = payload
        .get("rootUri")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .or_else(|| source_snapshot.map(|snapshot| snapshot.root_uri.as_str()))
        .unwrap_or("");
    let permission = if local_ai_payload_is_read_only(payload) {
        "read"
    } else {
        "write"
    };
    let actor_id = payload
        .get("actorId")
        .and_then(Value::as_str)
        .filter(|value| !value.trim().is_empty())
        .unwrap_or(&context.auth.actor_id);
    let actor_type = payload
        .get("actorType")
        .and_then(Value::as_str)
        .filter(|value| !value.trim().is_empty())
        .unwrap_or(&context.auth.actor_type);
    let changed_file_count = changed_files.as_array().map(Vec::len).unwrap_or_default();
    json!({
        "eventId": format!("local_audit:{}:{}", sanitize_id_part(run_id), now_ms()),
        "actorId": actor_id,
        "actorType": actor_type,
        "agentKind": acp_runtime,
        "runId": run_id,
        "sessionId": payload.get("sessionId").cloned().unwrap_or(Value::Null),
        "workspaceId": payload.get("workspaceId").cloned().unwrap_or(Value::Null),
        "documentId": payload.get("documentId").cloned().unwrap_or(Value::Null),
        "rootUri": root_uri,
        "permission": permission,
        "status": if write_attempt_rejected { "read_only_write_rejected" } else { status },
        "writeAttemptRejected": write_attempt_rejected,
        "changedFiles": changed_files,
        "diffSummary": format!("{changed_file_count} changed file(s)"),
        "createdAt": now_ms(),
    })
}

fn local_agent_audit_finalize_run(
    context: &RequestContext,
    payload: &Value,
    run_id: &str,
    acp_runtime: &str,
    status: &str,
) -> Result<Value, WebError> {
    let before = local_agent_audit_take_snapshot(run_id);
    let after = payload
        .get("rootUri")
        .and_then(Value::as_str)
        .map(local_agent_audit_collect_snapshot)
        .transpose()?;
    let changed_files = match (&before, &after) {
        (Some(before), Some(after)) => local_agent_audit_change_files(before, after),
        (None, Some(after)) => local_agent_audit_change_files(
            &LocalAgentAuditSnapshot {
                root_uri: after.root_uri.clone(),
                files: BTreeMap::new(),
            },
            after,
        ),
        (Some(before), None) => local_agent_audit_change_files(
            before,
            &LocalAgentAuditSnapshot {
                root_uri: before.root_uri.clone(),
                files: BTreeMap::new(),
            },
        ),
        (None, None) => Value::Array(vec![]),
    };
    let write_attempt_rejected = local_ai_payload_is_read_only(payload)
        && changed_files.as_array().map(Vec::len).unwrap_or_default() > 0;
    let event = local_agent_audit_event(
        context,
        payload,
        run_id,
        acp_runtime,
        status,
        changed_files,
        before.as_ref(),
        after.as_ref(),
        write_attempt_rejected,
    );
    local_agent_audit_write_event(&event)?;
    Ok(event)
}

pub(crate) fn local_agent_audit_record_write_rejected(
    context: &RequestContext,
    payload: Value,
    run_id: &str,
    acp_runtime: &str,
) -> Result<Value, WebError> {
    let mut event = local_agent_audit_event(
        context,
        &payload,
        run_id,
        acp_runtime,
        "read_only_write_rejected",
        Value::Array(Vec::new()),
        None,
        None,
        true,
    );
    if let Some(object) = event.as_object_mut() {
        object.insert(
            "toolName".into(),
            payload.get("toolName").cloned().unwrap_or(Value::Null),
        );
        object.insert(
            "toolCallId".into(),
            payload.get("toolCallId").cloned().unwrap_or(Value::Null),
        );
        object.insert(
            "rejection".into(),
            payload.get("rejection").cloned().unwrap_or(Value::Null),
        );
    }
    local_agent_audit_write_event(&event)?;
    Ok(event)
}

pub(crate) fn local_agent_audit_record_tool_write(
    context: &RequestContext,
    payload: Value,
    run_id: &str,
    acp_runtime: &str,
) -> Result<Value, WebError> {
    let changed_files = payload
        .get("changedFiles")
        .cloned()
        .unwrap_or_else(|| Value::Array(Vec::new()));
    let mut event = local_agent_audit_event(
        context,
        &payload,
        run_id,
        acp_runtime,
        "completed",
        changed_files,
        None,
        None,
        false,
    );
    if let Some(object) = event.as_object_mut() {
        object.insert(
            "toolName".into(),
            payload.get("toolName").cloned().unwrap_or(Value::Null),
        );
        object.insert(
            "toolCallId".into(),
            payload.get("toolCallId").cloned().unwrap_or(Value::Null),
        );
        object.insert(
            "commandName".into(),
            payload.get("commandName").cloned().unwrap_or(Value::Null),
        );
        object.insert("origin".into(), Value::String("mnote_tool".into()));
    }
    local_agent_audit_write_event(&event)?;
    Ok(event)
}

fn local_ai_session_dir(root_uri: &str, share_id: Option<&str>) -> Result<PathBuf, WebError> {
    let root = local_ai_session_root_dir(root_uri)?;
    let mut dir = root.join("ai-sessions");
    if share_id
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .is_some()
    {
        dir = dir.join("shared").join(sanitize_id_part(share_id.unwrap()));
    } else {
        dir = dir.join("private");
    }
    Ok(dir)
}

fn local_ai_session_path(
    root_uri: &str,
    session_id: &str,
    share_id: Option<&str>,
) -> Result<PathBuf, WebError> {
    Ok(local_ai_session_dir(root_uri, share_id)?
        .join(format!("{}.jsonl", sanitize_id_part(session_id))))
}

fn append_local_ai_session_event(
    root_uri: &str,
    session_id: &str,
    event: &Value,
    share_id: Option<&str>,
) -> Result<(), WebError> {
    let dir = local_ai_session_dir(root_uri, share_id)?;
    fs::create_dir_all(&dir).map_err(|error| {
        WebError::bad_request_code(
            "local_ai_session_dir_create_failed",
            format!("无法创建本地 AI 会话目录 {}: {error}", dir.display()),
        )
    })?;
    let path = local_ai_session_path(root_uri, session_id, share_id)?;
    let mut file = fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&path)
        .map_err(|error| {
            WebError::bad_request_code(
                "local_ai_session_write_failed",
                format!("无法写入本地 AI 会话文件 {}: {error}", path.display()),
            )
        })?;
    writeln!(
        file,
        "{}",
        serde_json::to_string(event).map_err(|error| {
            WebError::bad_request_code(
                "local_ai_session_serialize_failed",
                format!("无法序列化本地 AI 会话事件: {error}"),
            )
        })?
    )
    .map_err(|error| {
        WebError::bad_request_code(
            "local_ai_session_write_failed",
            format!("无法写入本地 AI 会话文件 {}: {error}", path.display()),
        )
    })?;
    Ok(())
}

fn read_local_ai_session_events(
    root_uri: &str,
    session_id: &str,
    share_id: Option<&str>,
) -> Result<Vec<Value>, WebError> {
    let path = local_ai_session_path(root_uri, session_id, share_id)?;
    let content = fs::read_to_string(&path).unwrap_or_default();
    Ok(content
        .lines()
        .filter_map(|line| serde_json::from_str::<Value>(line).ok())
        .collect())
}

fn list_local_ai_sessions(root_uri: &str, limit: usize) -> Result<Value, WebError> {
    let dir = local_ai_session_dir(root_uri, None)?;
    let mut rows = Vec::new();
    let Ok(entries) = fs::read_dir(&dir) else {
        return Ok(Value::Array(Vec::new()));
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.extension().and_then(|value| value.to_str()) != Some("jsonl") {
            continue;
        }
        let session_id = path
            .file_stem()
            .and_then(|value| value.to_str())
            .unwrap_or_default()
            .to_string();
        let events = fs::read_to_string(&path)
            .unwrap_or_default()
            .lines()
            .filter_map(|line| serde_json::from_str::<Value>(line).ok())
            .collect::<Vec<_>>();
        let first = events.first().cloned().unwrap_or(Value::Null);
        let last = events.last().cloned().unwrap_or(Value::Null);
        rows.push(json!({
            "sessionId": session_id,
            "title": first.get("title").and_then(Value::as_str).unwrap_or("当前页问答"),
            "profile": first.get("profile").and_then(Value::as_str).unwrap_or("reasonix"),
            "sourceKind": "local_folder",
            "sessionStorage": "local_private",
            "persistence": "local_ai_session_jsonl",
            "createdAt": first.get("createdAt").cloned().unwrap_or(Value::Null),
            "updatedAt": last.get("createdAt").cloned().unwrap_or(Value::Null),
            "status": last.get("status").or_else(|| last.get("eventType")).cloned().unwrap_or(Value::Null),
            "payload": first
        }));
    }
    rows.sort_by(|a, b| {
        b.get("updatedAt")
            .and_then(Value::as_u64)
            .cmp(&a.get("updatedAt").and_then(Value::as_u64))
    });
    rows.truncate(limit);
    Ok(Value::Array(rows))
}

async fn persist_acp_runtime_run(
    state: &AppState,
    context: &RequestContext,
    registration: &HermesRunRegistration,
    run_id: &str,
    acp_runtime: &str,
    runtime_state: &Value,
    payload: &Value,
) -> Result<Value, WebError> {
    let args = acp_runtime_run_store_args(
        context,
        registration,
        run_id,
        acp_runtime,
        runtime_state,
        payload,
    );
    if payload
        .get("sourceKind")
        .and_then(Value::as_str)
        .map(str::trim)
        == Some("local_folder")
    {
        let root_uri = payload
            .get("rootUri")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .ok_or_else(|| {
                WebError::bad_request_code("local_ai_session_root_required", "缺少本地会话 rootUri")
                    .with_context(context)
            })?;
        let share_id = local_session_share_id(payload);
        if let Some(share_id) = share_id.as_deref() {
            load_local_share_grant_for_actor(
                share_id,
                payload.get("actorId").and_then(Value::as_str),
                Some(root_uri),
            )
            .map_err(|error| error.with_context(context))?;
        } else {
            crate::routes::ensure_local_workspace_access(context, root_uri)
                .map_err(|error| error.with_context(context))?;
        }
        let mut event = args.clone();
        if let Value::Object(map) = &mut event {
            map.insert("eventType".into(), Value::String("run.started".into()));
            map.insert(
                "persistence".into(),
                Value::String("local_ai_session_jsonl".into()),
            );
        }
        append_local_ai_session_event(
            root_uri,
            &registration.session_id,
            &event,
            share_id.as_deref(),
        )
        .map_err(|error| error.with_context(context))?;
        return Ok(json!({
            "ok": true,
            "persistence": "local_ai_session_jsonl",
            "sessionStorage": if share_id.is_some() { "local_shared" } else { "local_private" },
            "sessionId": registration.session_id,
            "runId": run_id
        }));
    }
    let workspace_id = args
        .get("workspaceId")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned);
    let document_id = args
        .get("documentId")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned);
    let title = args
        .get("title")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned);
    let status = args
        .get("status")
        .and_then(Value::as_str)
        .unwrap_or("acp_pending")
        .to_string();
    let record = state
        .control_plane()
        .upsert_ai_runtime_run(UpsertAiRuntimeRunInput {
            id: None,
            user_id: runtime_store_user_id(context, payload),
            workspace_id,
            document_id,
            session_id: registration.session_id.clone(),
            run_id: run_id.to_string(),
            title,
            profile: registration.profile.clone(),
            acp_runtime: acp_runtime.to_string(),
            trace_id: Some(registration.trace_id.clone()),
            status,
            runtime_json: json_string(runtime_state),
            payload_json: json_string(payload),
        })
        .map_err(|error| {
            WebError::internal(format!("SQLite ACP runtime run 写入失败: {error}"))
                .with_context(context)
        })?;
    Ok(json!({
        "ok": true,
        "persistence": ACP_RUNTIME_SQLITE_STORE,
        "sessionStorage": "sqlite_control_plane",
        "sessionId": record.session_id,
        "runId": record.run_id
    }))
}

fn acp_runtime_event_store_args(
    context: &RequestContext,
    registration: &HermesRunRegistration,
    run_id: &str,
    acp_runtime: &str,
    event_type: &str,
    event_payload: &Value,
    run_payload: &Value,
) -> Value {
    let workspace_id = run_payload
        .get("workspaceId")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
        .or_else(|| context.workspace.workspace_id.clone());
    let created_at = now_ms();
    json!({
        "schema": "mnote.acp_runtime_event.v1",
        "source": "acp",
        "eventId": format!(
            "acp_event:{}:{}:{}",
            sanitize_id_part(run_id),
            created_at,
            sanitize_id_part(event_type)
        ),
        "userId": runtime_store_user_id(context, run_payload),
        "workspaceId": workspace_id,
        "documentId": registration.document_id,
        "sessionId": registration.session_id,
        "runId": run_id,
        "profile": registration.profile,
        "acpRuntime": acp_runtime,
        "eventType": event_type,
        "payload": event_payload,
        "createdAt": created_at
    })
}

async fn persist_acp_runtime_event(
    state: &AppState,
    context: &RequestContext,
    registration: &HermesRunRegistration,
    run_id: &str,
    acp_runtime: &str,
    event_type: &str,
    event_payload: &Value,
    run_payload: &Value,
) -> Result<Value, WebError> {
    let args = acp_runtime_event_store_args(
        context,
        registration,
        run_id,
        acp_runtime,
        event_type,
        event_payload,
        run_payload,
    );
    if run_payload
        .get("sourceKind")
        .and_then(Value::as_str)
        .map(str::trim)
        == Some("local_folder")
    {
        let root_uri = run_payload
            .get("rootUri")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .ok_or_else(|| {
                WebError::bad_request_code("local_ai_session_root_required", "缺少本地会话 rootUri")
                    .with_context(context)
            })?;
        let share_id = local_session_share_id(run_payload);
        if let Some(share_id) = share_id.as_deref() {
            load_local_share_grant_for_actor(
                share_id,
                run_payload.get("actorId").and_then(Value::as_str),
                Some(root_uri),
            )
            .map_err(|error| error.with_context(context))?;
        } else {
            crate::routes::ensure_local_workspace_access(context, root_uri)
                .map_err(|error| error.with_context(context))?;
        }
        let mut event = args.clone();
        if let Value::Object(map) = &mut event {
            map.insert(
                "persistence".into(),
                Value::String("local_ai_session_jsonl".into()),
            );
        }
        append_local_ai_session_event(
            root_uri,
            &registration.session_id,
            &event,
            share_id.as_deref(),
        )
        .map_err(|error| error.with_context(context))?;
        return Ok(json!({
            "ok": true,
            "persistence": "local_ai_session_jsonl",
            "sessionStorage": if share_id.is_some() { "local_shared" } else { "local_private" },
            "sessionId": registration.session_id,
            "runId": run_id
        }));
    }
    state
        .control_plane()
        .append_ai_runtime_event(AppendAiRuntimeEventInput {
            id: None,
            user_id: runtime_store_user_id(context, run_payload),
            workspace_id: args
                .get("workspaceId")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .map(ToOwned::to_owned),
            document_id: args
                .get("documentId")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .map(ToOwned::to_owned),
            session_id: registration.session_id.clone(),
            run_id: run_id.to_string(),
            profile: registration.profile.clone(),
            acp_runtime: acp_runtime.to_string(),
            event_type: event_type.to_string(),
            payload_json: json_string(event_payload),
        })
        .map_err(|error| {
            WebError::internal(format!("SQLite ACP runtime event 写入失败: {error}"))
                .with_context(context)
        })?;
    Ok(json!({
        "ok": true,
        "persistence": ACP_RUNTIME_SQLITE_STORE,
        "sessionStorage": "sqlite_control_plane",
        "sessionId": registration.session_id,
        "runId": run_id
    }))
}

fn register_runtime_from_create_run_response(
    registration: &HermesRunRegistration,
    payload: &Value,
) -> Option<Value> {
    let upstream = payload.get("upstream").unwrap_or(payload);
    let run_id = upstream
        .get("run_id")
        .or_else(|| upstream.get("runId"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())?
        .to_string();
    let now = now_ms();
    let state = HermesRuntimeState {
        session_id: registration.session_id.clone(),
        run_id,
        profile: registration.profile.clone(),
        document_id: registration.document_id.clone(),
        trace_id: registration.trace_id.clone(),
        status: "running".into(),
        started_at: now,
        last_event_at: now,
        last_event: Some("run.started".into()),
        last_tool_name: None,
        last_tool_call_id: None,
        last_audit_id: None,
    };
    let json = runtime_state_to_json(&state);
    HERMES_RUNTIME_REGISTRY
        .lock()
        .expect("hermes runtime registry")
        .insert(registration.session_id.clone(), state);
    Some(json)
}

fn update_runtime_by_run_id(
    run_id: &str,
    status: &str,
    event: Option<&str>,
    tool: Option<(Option<String>, Option<String>, Option<String>)>,
) {
    let mut registry = HERMES_RUNTIME_REGISTRY
        .lock()
        .expect("hermes runtime registry");
    let Some(state) = registry.values_mut().find(|state| state.run_id == run_id) else {
        return;
    };
    state.status = status.to_string();
    state.last_event_at = now_ms();
    if let Some(event) = event {
        state.last_event = Some(event.to_string());
    }
    if let Some((tool_name, tool_call_id, audit_id)) = tool {
        if let Some(tool_name) = tool_name {
            state.last_tool_name = Some(tool_name);
        }
        if let Some(tool_call_id) = tool_call_id {
            state.last_tool_call_id = Some(tool_call_id);
        }
        if let Some(audit_id) = audit_id {
            state.last_audit_id = Some(audit_id);
        }
    }
}

fn normalize_sse_chunk(run_id: &str, chunk: &str) -> (String, Vec<String>) {
    let events = sse_json_events(chunk);
    let terminal_sessions = update_runtime_from_events(run_id, &events);
    let mut output = String::new();
    for event in events {
        let normalized = normalize_runtime_event(run_id, event);
        let event_name = normalized
            .get("event")
            .and_then(Value::as_str)
            .unwrap_or("runtime.event");
        output.push_str("event: ");
        output.push_str(event_name);
        output.push_str("\ndata: ");
        output.push_str(&normalized.to_string());
        output.push_str("\n\n");
    }
    if output.is_empty() {
        output.push_str(chunk);
    }
    (output, terminal_sessions)
}

fn update_runtime_from_events(run_id: &str, events: &[Value]) -> Vec<String> {
    let mut terminal_sessions = Vec::new();
    for event in events {
        let event_name = canonical_event_name(event);
        if event_name.is_empty() {
            continue;
        }
        let next_status = match event_name.as_str() {
            "tool.started" => "tool_calling",
            "tool.completed" | "tool.failed" => "running",
            "run.completed" => "completed",
            "run.failed" => "failed",
            "run.aborted" => "aborted",
            _ => "running",
        };
        let tool = if event_name.starts_with("tool.") {
            Some((
                event
                    .get("name")
                    .or_else(|| event.get("tool"))
                    .or_else(|| event.get("toolName"))
                    .and_then(Value::as_str)
                    .map(ToOwned::to_owned),
                event
                    .get("tool_call_id")
                    .or_else(|| event.get("toolCallId"))
                    .or_else(|| event.get("id"))
                    .and_then(Value::as_str)
                    .map(ToOwned::to_owned),
                event
                    .get("audit_id")
                    .or_else(|| event.get("auditId"))
                    .and_then(Value::as_str)
                    .map(ToOwned::to_owned),
            ))
        } else {
            None
        };
        update_runtime_by_run_id(run_id, next_status, Some(&event_name), tool);
        if matches!(next_status, "completed" | "failed" | "aborted") {
            if let Some(session_id) = session_id_for_run(run_id) {
                terminal_sessions.push(session_id);
            }
        }
    }
    terminal_sessions
}

fn canonical_event_name(event: &Value) -> String {
    let raw = event
        .get("event")
        .and_then(Value::as_str)
        .or_else(|| event.get("type").and_then(Value::as_str))
        .unwrap_or_default();
    match raw {
        "assistant_message" | "response.output_text.delta" => "message.delta",
        "response.completed" => "run.completed",
        "response.failed" => "run.failed",
        "run.cancelled" | "run.canceled" | "abort.completed" => "run.aborted",
        value => value,
    }
    .to_string()
}

fn normalize_runtime_event(run_id: &str, event: Value) -> Value {
    let event_name = canonical_event_name(&event);
    let session_id = event
        .get("session_id")
        .or_else(|| event.get("sessionId"))
        .and_then(Value::as_str)
        .map(ToOwned::to_owned)
        .or_else(|| session_id_for_run(run_id));
    let trace_id = event
        .get("trace_id")
        .or_else(|| event.get("traceId"))
        .and_then(Value::as_str)
        .unwrap_or_default();
    let mut normalized = json!({
        "schemaVersion": "mnote.hermes_runtime_event.v1",
        "event": event_name,
        "runId": event.get("run_id").or_else(|| event.get("runId")).and_then(Value::as_str).unwrap_or(run_id),
        "sessionId": session_id,
        "traceId": trace_id,
        "upstream": event
    });
    if event_name == "message.delta" {
        normalized["delta"] = event
            .get("delta")
            .or_else(|| event.get("text"))
            .or_else(|| event.get("output_text"))
            .cloned()
            .unwrap_or(Value::Null);
    }
    if event_name.starts_with("tool.") {
        normalized["toolName"] = event
            .get("name")
            .or_else(|| event.get("tool"))
            .or_else(|| event.get("toolName"))
            .cloned()
            .unwrap_or(Value::Null);
        normalized["toolCallId"] = event
            .get("tool_call_id")
            .or_else(|| event.get("toolCallId"))
            .or_else(|| event.get("id"))
            .cloned()
            .unwrap_or(Value::Null);
        normalized["args"] = event
            .get("arguments")
            .or_else(|| event.get("args"))
            .or_else(|| event.get("input"))
            .cloned()
            .unwrap_or(Value::Null);
        normalized["summary"] = event
            .get("summary")
            .or_else(|| event.get("result"))
            .or_else(|| event.get("output"))
            .cloned()
            .unwrap_or(Value::Null);
        normalized["code"] = event.get("code").cloned().unwrap_or(Value::Null);
        normalized["error"] = event.get("error").cloned().unwrap_or(Value::Null);
        normalized["auditId"] = event
            .get("audit_id")
            .or_else(|| event.get("auditId"))
            .cloned()
            .unwrap_or(Value::Null);
    }
    if event_name == "run.completed" {
        normalized["output"] = event.get("output").cloned().unwrap_or(Value::Null);
        normalized["usage"] = event.get("usage").cloned().unwrap_or(Value::Null);
    }
    if event_name == "run.failed" {
        normalized["code"] = event.get("code").cloned().unwrap_or(Value::Null);
        normalized["message"] = event
            .get("message")
            .or_else(|| event.get("error"))
            .cloned()
            .unwrap_or(Value::Null);
    }
    normalized
}

fn session_id_for_run(run_id: &str) -> Option<String> {
    let registry = HERMES_RUNTIME_REGISTRY
        .lock()
        .expect("hermes runtime registry");
    registry
        .values()
        .find(|state| state.run_id == run_id)
        .map(|state| state.session_id.clone())
}

fn profile_for_run(run_id: &str) -> Option<String> {
    let registry = HERMES_RUNTIME_REGISTRY
        .lock()
        .expect("hermes runtime registry");
    registry
        .values()
        .find(|state| state.run_id == run_id)
        .map(|state| state.profile.clone())
}

fn sse_json_events(chunk: &str) -> Vec<Value> {
    chunk
        .split("\n\n")
        .filter_map(|block| {
            let data = block
                .lines()
                .filter_map(|line| line.strip_prefix("data:"))
                .map(str::trim)
                .collect::<Vec<_>>()
                .join("\n");
            if data.is_empty() || data == "[DONE]" {
                return None;
            }
            serde_json::from_str::<Value>(&data).ok()
        })
        .collect()
}

async fn proxy_json(
    context: &RequestContext,
    method: reqwest::Method,
    upstream: &str,
    path: &str,
    body: Option<Value>,
    profile: Option<&str>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    let url = upstream_url(upstream, path)?;
    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(1800))
        .build()
        .map_err(|error| {
            WebError::internal(format!("Hermes client 构造失败: {error}")).with_context(context)
        })?;
    let mut request = client.request(method, url);
    if let Some(api_key) = profile
        .and_then(configured_api_key_for_profile)
        .or_else(configured_api_key)
    {
        request = request.bearer_auth(api_key);
    }
    if let Some(body) = body {
        request = request.json(&body);
    }
    let response = request.send().await.map_err(|error| {
        WebError::bad_gateway_code(
            "hermes_client_upstream_unavailable",
            format!("Hermes upstream 连接失败: {error}"),
        )
        .with_context(context)
        .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
    })?;
    let status = response.status();
    let text = response.text().await.unwrap_or_default();
    if !status.is_success() {
        return Err(upstream_error(context, status, text));
    }
    let payload = serde_json::from_str::<Value>(&text).unwrap_or_else(|_| {
        json!({
            "ok": true,
            "traceId": context.trace.trace_id,
            "raw": text
        })
    });
    Ok((
        StatusCode::OK,
        stamp_client_headers(),
        Json(normalize_success_payload(context, payload)),
    ))
}

fn session_has_active_run(session_id: &str) -> bool {
    let registry = HERMES_RUNTIME_REGISTRY
        .lock()
        .expect("hermes runtime registry");
    registry
        .get(session_id)
        .map(|state| {
            matches!(
                state.status.as_str(),
                "queued" | "running" | "tool_calling" | "aborting"
            )
        })
        .unwrap_or(false)
}

fn queue_len_for_session(session_id: &str) -> usize {
    HERMES_RUN_QUEUE
        .lock()
        .expect("hermes run queue")
        .get(session_id)
        .map(VecDeque::len)
        .unwrap_or_default()
}

fn queued_run_to_json(queued: &HermesQueuedRun, queue_length: usize) -> Value {
    json!({
        "ok": true,
        "queued": true,
        "queueId": queued.queue_id,
        "sessionId": queued.session_id,
        "profile": queued.profile,
        "documentId": queued.document_id,
        "traceId": queued.trace_id,
        "status": "queued",
        "queueLength": queue_length,
        "queuedAt": queued.queued_at,
        "contextSummary": queued.context_summary
    })
}

fn enqueue_run(
    context: &RequestContext,
    registration: &HermesRunRegistration,
    payload: &Value,
) -> Result<Value, WebError> {
    let input = extract_run_message(payload).ok_or_else(|| {
        WebError::bad_request_code("hermes_client_bad_request", "缺少 message")
            .with_context(context)
            .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
            .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
    })?;
    let queued = HermesQueuedRun {
        queue_id: format!(
            "queue_{}_{}",
            sanitize_id_part(&registration.trace_id),
            now_ms()
        ),
        session_id: registration.session_id.clone(),
        profile: registration.profile.clone(),
        document_id: registration.document_id.clone(),
        trace_id: registration.trace_id.clone(),
        actor_id: payload
            .get("actorId")
            .and_then(Value::as_str)
            .unwrap_or("anonymous")
            .to_string(),
        actor_type: payload
            .get("actorType")
            .and_then(Value::as_str)
            .unwrap_or("anonymous")
            .to_string(),
        input,
        context_summary: queue_context_summary(payload),
        queued_at: now_ms(),
    };
    let queue_length = {
        let mut queue = HERMES_RUN_QUEUE.lock().expect("hermes run queue");
        let entries = queue.entry(registration.session_id.clone()).or_default();
        entries.push_back(queued.clone());
        entries.len()
    };
    Ok(queued_run_to_json(&queued, queue_length))
}

async fn start_next_queued_run(context: RequestContext, session_id: String) {
    if session_has_active_run(&session_id) {
        return;
    }
    let Some(queued) = pop_next_queued_run(&session_id) else {
        return;
    };
    let Some(upstream) = configured_upstream_for_profile(&queued.profile) else {
        warn!(session_id = %session_id, queue_id = %queued.queue_id, "Hermes queue 无 upstream，无法自动启动下一条 run");
        return;
    };
    let payload = queued_run_payload(&queued);
    let upstream_body = match build_run_upstream_body(&context, payload) {
        Ok(body) => body,
        Err(error) => {
            warn!(session_id = %session_id, queue_id = %queued.queue_id, error = ?error, "Hermes queue 构造 run body 失败");
            return;
        }
    };
    let registration = HermesRunRegistration {
        session_id: queued.session_id.clone(),
        profile: queued.profile.clone(),
        document_id: queued.document_id.clone(),
        trace_id: queued.trace_id.clone(),
    };
    match proxy_json(
        &context,
        reqwest::Method::POST,
        &upstream,
        "/v1/runs",
        Some(upstream_body),
        Some(&queued.profile),
    )
    .await
    {
        Ok((_, _, Json(payload))) => {
            let _ = register_runtime_from_create_run_response(&registration, &payload);
        }
        Err(error) => {
            warn!(session_id = %session_id, queue_id = %queued.queue_id, error = ?error, "Hermes queue 自动启动下一条 run 失败");
        }
    }
}

fn queued_run_payload(queued: &HermesQueuedRun) -> Value {
    json!({
        "documentId": queued.document_id,
        "sessionId": queued.session_id,
        "profile": queued.profile,
        "message": queued.input,
        "traceId": queued.trace_id,
        "actorId": queued.actor_id,
        "actorType": queued.actor_type,
        "contextScope": queued.context_summary.get("contextScope").cloned().unwrap_or(Value::Null)
    })
}

fn remove_queued_run(session_id: &str, queue_id: &str) -> bool {
    let mut queue = HERMES_RUN_QUEUE.lock().expect("hermes run queue");
    let Some(entries) = queue.get_mut(session_id) else {
        return false;
    };
    let before = entries.len();
    entries.retain(|item| item.queue_id != queue_id);
    let removed = entries.len() != before;
    if entries.is_empty() {
        queue.remove(session_id);
    }
    removed
}

fn pop_next_queued_run(session_id: &str) -> Option<HermesQueuedRun> {
    let mut queue = HERMES_RUN_QUEUE.lock().expect("hermes run queue");
    let entries = queue.get_mut(session_id)?;
    let next = entries.pop_front();
    if entries.is_empty() {
        queue.remove(session_id);
    }
    next
}

fn extract_run_message(payload: &Value) -> Option<String> {
    payload
        .get("message")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
        .or_else(|| {
            payload
                .get("messages")
                .and_then(Value::as_array)
                .and_then(|messages| messages.last())
                .and_then(|message| message.get("content"))
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .map(ToOwned::to_owned)
        })
}

fn queue_context_summary(payload: &Value) -> Value {
    let page_context = payload.get("pageContext").unwrap_or(&Value::Null);
    json!({
        "contextScope": payload.get("contextScope").cloned().unwrap_or(Value::Null),
        "selectedBlockId": payload.get("selectedBlockId").cloned().unwrap_or(Value::Null),
        "hasSelectedText": payload.get("selectedText").and_then(Value::as_str).map(|value| !value.trim().is_empty()).unwrap_or(false),
        "hasPageContext": !page_context.is_null(),
        "pageContextKeys": page_context.as_object().map(|object| object.keys().take(12).cloned().collect::<Vec<_>>()).unwrap_or_default()
    })
}

fn normalize_success_payload(context: &RequestContext, payload: Value) -> Value {
    if payload.get("ok").is_some() {
        payload
    } else {
        json!({
            "ok": true,
            "traceId": context.trace.trace_id,
            "upstream": payload
        })
    }
}

fn upstream_error(context: &RequestContext, status: reqwest::StatusCode, text: String) -> WebError {
    let (response_status, code) = match status {
        reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => (
            StatusCode::BAD_GATEWAY,
            "hermes_client_upstream_unauthorized",
        ),
        reqwest::StatusCode::TOO_MANY_REQUESTS => (
            StatusCode::TOO_MANY_REQUESTS,
            "hermes_client_upstream_rate_limited",
        ),
        status if status.is_server_error() => (
            StatusCode::BAD_GATEWAY,
            "hermes_client_upstream_unavailable",
        ),
        _ => (StatusCode::BAD_GATEWAY, "hermes_client_upstream_error"),
    };
    let suggestions = hermes_settings_suggestions(Some(status.as_u16() as u64), Some(&text));
    WebError::new(
        response_status,
        code,
        format!(
            "Hermes upstream 返回 HTTP {}: {}。建议：{}",
            status.as_u16(),
            text.chars().take(600).collect::<String>(),
            suggestions.join("；")
        ),
    )
    .with_context(context)
    .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
    .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
    .with_header("x-hermes-client-suggestion", suggestions.join(" | "))
}

fn hermes_unconfigured(
    context: &RequestContext,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    Err(hermes_unconfigured_error(context))
}

fn hermes_unconfigured_error(context: &RequestContext) -> WebError {
    WebError::service_unavailable_code(
        "hermes_client_unconfigured",
        "Hermes client proxy 未配置 MNOTE_WEB_HERMES_UPSTREAM_URL",
    )
    .with_context(context)
    .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
    .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
}

fn upstream_url(upstream: &str, path: &str) -> Result<String, WebError> {
    let url = format!(
        "{}/{}",
        upstream.trim_end_matches('/'),
        path.trim_start_matches('/')
    );
    reqwest::Url::parse(&url)
        .map(|url| url.to_string())
        .map_err(|error| WebError::internal(format!("Hermes upstream URL 无效: {error}")))
}

fn stable_session_id(document_id: &str, trace_id: &str) -> String {
    format!(
        "mnote_{}_{}",
        sanitize_id_part(document_id),
        sanitize_id_part(trace_id)
    )
}

fn sanitize_id_part(value: &str) -> String {
    let sanitized = value
        .chars()
        .map(|ch| {
            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
                ch
            } else {
                '_'
            }
        })
        .collect::<String>();
    if sanitized.is_empty() {
        "current".into()
    } else {
        sanitized
    }
}

fn url_escape(value: &str) -> String {
    value
        .bytes()
        .flat_map(|byte| match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                vec![byte as char]
            }
            _ => format!("%{byte:02X}").chars().collect(),
        })
        .collect()
}

fn stamp_client_headers() -> HeaderMap {
    let mut headers = HeaderMap::new();
    stamp_client_headers_into(&mut headers);
    headers
}

fn stamp_client_headers_into(headers: &mut HeaderMap) {
    if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
        headers.insert(name, HeaderValue::from_static("mnote-web"));
    }
    if let Ok(name) = HeaderName::from_lowercase(HEADER_HERMES_CLIENT_OWNER.as_bytes()) {
        headers.insert(name, HeaderValue::from_static("mnote-web-hermes-client"));
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::app::{build_app, AppConfig, AppState};
    use axum::body::{to_bytes, Body};
    use axum::http::Request;
    use axum::routing::{get, post};
    use control_plane::UpsertAiRuntimeRunInput;
    use std::sync::atomic::{AtomicUsize, Ordering};
    use std::sync::{Arc, Mutex, OnceLock};
    use tower::util::ServiceExt;

    fn env_lock() -> &'static Mutex<()> {
        crate::test_support::hermes_env_lock()
    }

    fn runtime_lock() -> &'static Mutex<()> {
        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
        LOCK.get_or_init(|| Mutex::new(()))
    }

    fn clear_runtime_registry() {
        HERMES_RUNTIME_REGISTRY
            .lock()
            .expect("hermes runtime registry")
            .clear();
    }

    fn clear_run_queue() {
        HERMES_RUN_QUEUE.lock().expect("hermes run queue").clear();
    }

    fn clear_acp_run_payloads() {
        ACP_RUN_PAYLOADS.lock().expect("acp run payloads").clear();
    }

    #[test]
    fn local_agent_audit_snapshot_detects_changed_files() {
        let root =
            std::env::temp_dir().join(format!("mnote-local-agent-audit-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(root.join(".mnote")).expect("create metadata dir");
        std::fs::create_dir_all(root.join("ai-sessions/private")).expect("create session dir");
        std::fs::write(root.join("a.md"), "# A\nold\n").expect("write a");
        std::fs::write(root.join(".mnote/ignored.md"), "ignored").expect("write ignored");
        std::fs::write(root.join("ai-sessions/private/event.jsonl"), "ignored")
            .expect("write session");
        let root_uri = format!("file://{}", root.display());
        let before = local_agent_audit_collect_snapshot(&root_uri).expect("before snapshot");

        std::fs::write(root.join("a.md"), "# A\nnew\n").expect("modify a");
        std::fs::write(root.join("b.md"), "# B\n").expect("write b");
        let after = local_agent_audit_collect_snapshot(&root_uri).expect("after snapshot");
        let changed = local_agent_audit_change_files(&before, &after);
        let files = changed.as_array().expect("changed files");

        assert_eq!(files.len(), 2);
        assert!(files
            .iter()
            .any(|file| { file["path"] == "a.md" && file["changeType"] == "modified" }));
        assert!(files
            .iter()
            .any(|file| { file["path"] == "b.md" && file["changeType"] == "added" }));
        assert!(!files.iter().any(|file| {
            file["path"]
                .as_str()
                .map(|path| path.contains(".mnote") || path.contains("ai-sessions"))
                .unwrap_or(false)
        }));

        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn local_agent_audit_snapshot_detects_resource_files() {
        let root = std::env::temp_dir().join(format!(
            "mnote-local-agent-audit-resources-{}",
            std::process::id()
        ));
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(root.join("maps")).expect("create maps dir");
        std::fs::create_dir_all(root.join("office")).expect("create office dir");
        std::fs::write(root.join("maps/a.mindmap.json"), r#"{"root":"old"}"#)
            .expect("write mindmap");
        std::fs::write(root.join("office/a.docx"), [0xff, 1, 2, 3]).expect("write office");
        let root_uri = format!("file://{}", root.display());
        let before = local_agent_audit_collect_snapshot(&root_uri).expect("before snapshot");

        std::fs::write(root.join("maps/a.mindmap.json"), r#"{"root":"new"}"#)
            .expect("modify mindmap");
        std::fs::write(root.join("office/a.docx"), [0xfe, 1, 2, 3]).expect("modify office");
        let after = local_agent_audit_collect_snapshot(&root_uri).expect("after snapshot");
        let changed = local_agent_audit_change_files(&before, &after);
        let files = changed.as_array().expect("changed files");

        assert!(files.iter().any(|file| {
            file["path"] == "maps/a.mindmap.json" && file["changeType"] == "modified"
        }));
        assert!(files
            .iter()
            .any(|file| { file["path"] == "office/a.docx" && file["changeType"] == "modified" }));

        let _ = std::fs::remove_dir_all(&root);
    }

    fn app() -> axum::Router {
        app_with_config(AppConfig {
            service_name: "mnote-web".into(),
            service_version: "0.1.0".into(),
            bind_addr: "127.0.0.1:0".into(),
            public_bind_addr: "127.0.0.1:3000".into(),
            legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
            enable_legacy_next_compat: true,
            enable_debug_shell_routes: false,
            enable_editor_actor: true,
            hermes_base_path: "/api/hermes".into(),
            compat_next_base_path: "/api/compat/next".into(),
            convex_url: None,
            convex_admin_key: None,
            allow_dev_fixtures: true,
            query_fixtures_json: None,
            mutation_fixtures_json: None,
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        })
    }

    fn app_with_config(config: AppConfig) -> axum::Router {
        build_app(AppState::new(config))
    }

    fn seeded_acp_state() -> AppState {
        let state = AppState::new(AppConfig {
            service_name: "mnote-web".into(),
            service_version: "0.1.0".into(),
            bind_addr: "127.0.0.1:0".into(),
            public_bind_addr: "127.0.0.1:3000".into(),
            legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
            enable_legacy_next_compat: true,
            enable_debug_shell_routes: false,
            enable_editor_actor: true,
            hermes_base_path: "/api/hermes".into(),
            compat_next_base_path: "/api/compat/next".into(),
            convex_url: None,
            convex_admin_key: None,
            allow_dev_fixtures: false,
            query_fixtures_json: None,
            mutation_fixtures_json: None,
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        });
        state
            .control_plane()
            .upsert_ai_runtime_run(UpsertAiRuntimeRunInput {
                id: None,
                user_id: "user_1".to_string(),
                workspace_id: Some("ws_1".to_string()),
                document_id: Some("doc_1".to_string()),
                session_id: "sess_1".to_string(),
                run_id: "run_1".to_string(),
                title: None,
                profile: "reasonix".to_string(),
                acp_runtime: "reasonix".to_string(),
                trace_id: Some("trace_1".to_string()),
                status: "completed".to_string(),
                runtime_json: "{\"status\":\"completed\",\"runId\":\"run_1\"}".to_string(),
                payload_json: "{\"message\":\"自动标题\"}".to_string(),
            })
            .expect("seed acp runtime run");
        state
    }

    fn test_state() -> AppState {
        AppState::new(AppConfig {
            service_name: "mnote-web".into(),
            service_version: "0.1.0".into(),
            bind_addr: "127.0.0.1:0".into(),
            public_bind_addr: "127.0.0.1:3000".into(),
            legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
            enable_legacy_next_compat: true,
            enable_debug_shell_routes: false,
            enable_editor_actor: true,
            hermes_base_path: "/api/hermes".into(),
            compat_next_base_path: "/api/compat/next".into(),
            convex_url: None,
            convex_admin_key: None,
            allow_dev_fixtures: true,
            query_fixtures_json: None,
            mutation_fixtures_json: None,
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        })
    }

    #[test]
    fn empty_runtime_defaults_to_acp_reasonix_when_http_proxy_disabled() {
        let _env_guard = env_lock().lock().expect("env lock");
        std::env::remove_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY");
        std::env::remove_var("MNOTE_ENABLE_HERMES_HTTP_PROXY");
        let payload = json!({
            "profile": "mnoteai",
            "acpRuntime": ""
        });
        assert_eq!(
            acp_runtime_for_payload(&payload, "mnoteai"),
            Some("reasonix".into())
        );
    }

    #[test]
    fn empty_runtime_can_still_use_retired_http_proxy_behind_explicit_flag() {
        let _env_guard = env_lock().lock().expect("env lock");
        std::env::set_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY", "1");
        std::env::remove_var("MNOTE_ENABLE_HERMES_HTTP_PROXY");
        let payload = json!({
            "profile": "mnoteai",
            "acpRuntime": ""
        });
        assert_eq!(acp_runtime_for_payload(&payload, "mnoteai"), None);
        std::env::remove_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY");
    }

    #[tokio::test]
    async fn hermes_client_gateway_health_reports_unconfigured_profile_settings() {
        let _env_guard = env_lock().lock().expect("env lock");
        std::env::set_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY", "1");
        std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL");
        std::env::remove_var("MNOTE_HERMES_UPSTREAM_URL");
        std::env::remove_var("MNOTE_HERMES_API_BASE_URL");
        let hermes_home = std::env::temp_dir().join(format!(
            "mnote-web-hermes-health-unconfigured-{}",
            std::process::id()
        ));
        let _ = fs::remove_dir_all(&hermes_home);
        fs::create_dir_all(&hermes_home).expect("hermes home");
        std::env::set_var("HERMES_HOME", &hermes_home);

        let response = app()
            .oneshot(
                Request::builder()
                    .method("GET")
                    .uri("/api/hermes/client/gateway/health?profile=default")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["ok"], false);
        assert_eq!(payload["gateway"]["status"], "unconfigured");
        assert_eq!(payload["profile"]["modelConfigured"], false);
        let suggestions = payload["suggestions"].to_string();
        assert!(suggestions.contains("MNOTE_WEB_HERMES_UPSTREAM_URL"));
        assert!(suggestions.contains("model.default"));

        std::env::remove_var("HERMES_HOME");
        std::env::remove_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY");
        let _ = fs::remove_dir_all(&hermes_home);
    }

    #[tokio::test]
    async fn hermes_client_gateway_health_reports_profile_model_and_api_key_gaps() {
        let _env_guard = env_lock().lock().expect("env lock");
        std::env::set_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY", "1");
        let hermes_home = std::env::temp_dir().join(format!(
            "mnote-web-hermes-health-profile-{}",
            std::process::id()
        ));
        let _ = fs::remove_dir_all(&hermes_home);
        let profile_dir = hermes_home.join("profiles").join("chemist");
        fs::create_dir_all(&profile_dir).expect("profile dir");
        fs::write(
            profile_dir.join("config.yaml"),
            "model:\n  provider: openai\nproviders:\n  openai:\n    base_url: https://api.openai.com/v1\n",
        )
        .expect("profile config");
        std::env::set_var("HERMES_HOME", &hermes_home);

        let mock = axum::Router::new().route(
            "/v1/models",
            get(|| async { (StatusCode::BAD_REQUEST, "missing model.default and API key") }),
        );
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("listener");
        let upstream = format!("http://{}", listener.local_addr().expect("addr"));
        tokio::spawn(async move {
            axum::serve(listener, mock).await.expect("mock upstream");
        });
        std::env::set_var("MNOTE_WEB_HERMES_UPSTREAM_URL", &upstream);

        let response = app()
            .oneshot(
                Request::builder()
                    .method("GET")
                    .uri("/api/hermes/client/gateway/health?profile=chemist")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["ok"], false);
        assert_eq!(payload["gateway"]["status"], "upstream_error");
        assert_eq!(payload["profile"]["name"], "chemist");
        assert_eq!(payload["profile"]["modelConfigured"], false);
        assert_eq!(payload["profile"]["apiKeyConfigured"], false);
        let suggestions = payload["suggestions"].to_string();
        assert!(suggestions.contains("model.default"));
        assert!(suggestions.contains("API key"));

        std::env::remove_var("HERMES_HOME");
        std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL");
        std::env::remove_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY");
        let _ = fs::remove_dir_all(&hermes_home);
    }

    #[tokio::test]
    async fn hermes_client_upstream_error_message_points_to_hermes_settings() {
        let _env_guard = env_lock().lock().expect("env lock");
        let _runtime_guard = runtime_lock().lock().expect("runtime lock");
        std::env::set_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY", "1");
        clear_runtime_registry();
        clear_run_queue();
        let mock = axum::Router::new().route(
            "/v1/runs",
            post(|| async { (StatusCode::BAD_REQUEST, "missing model.default and API key") }),
        );
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("listener");
        let upstream = format!("http://{}", listener.local_addr().expect("addr"));
        tokio::spawn(async move {
            axum::serve(listener, mock).await.expect("mock upstream");
        });
        std::env::set_var("MNOTE_WEB_HERMES_UPSTREAM_URL", &upstream);
        std::env::remove_var("MNOTE_HERMES_UPSTREAM_URL");

        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/client/runs")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "documentId": "doc_error",
                            "sessionId": "mnote_doc_error_trace_1",
                            "profile": "chemist",
                            "message": "测试错误提示",
                            "traceId": "trace_error_1"
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["code"], "hermes_client_upstream_error");
        let message = payload["message"].as_str().unwrap_or_default();
        assert!(message.contains("model.default"));
        assert!(message.contains("API key"));
        assert!(message.contains("Hermes 设置"));

        std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL");
        std::env::remove_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY");
    }

    #[tokio::test]
    async fn hermes_client_unauthenticated_requests_return_401() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/client/sessions")
                    .header("content-type", "application/json")
                    .body(Body::from(json!({"documentId":"doc_1"}).to_string()))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
        assert_eq!(
            response
                .headers()
                .get("x-error-code")
                .and_then(|value| value.to_str().ok()),
            Some("hermes_client_unauthorized")
        );
    }

    #[tokio::test]
    async fn hermes_client_unconfigured_run_returns_stable_error() {
        let _guard = env_lock().lock().expect("env lock");
        std::env::set_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY", "1");
        std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL");
        std::env::remove_var("MNOTE_HERMES_UPSTREAM_URL");
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/client/runs")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "workspaceId": "ws_1",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "message": "ping",
                            "traceId": "trace_1"
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
        assert_eq!(
            response
                .headers()
                .get("x-error-code")
                .and_then(|value| value.to_str().ok()),
            Some("hermes_client_unconfigured")
        );
        std::env::remove_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY");
    }

    #[tokio::test]
    async fn hermes_client_session_create_uses_control_plane_store_without_storing_chat() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/client/sessions")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "workspaceId": "ws_1",
                            "documentId": "doc_1",
                            "traceId": "trace_1",
                            "title": "当前页问答"
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["ok"], true);
        assert_eq!(payload["sessionId"], "mnote_doc_1_trace_1");
        assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
        assert!(payload.get("messages").is_none());
    }

    #[tokio::test]
    async fn hermes_client_local_acp_session_create_writes_private_jsonl() {
        let root = std::env::temp_dir().join(format!(
            "mnote-local-ai-session-private-{}",
            std::process::id()
        ));
        let _ = fs::remove_dir_all(&root);
        fs::create_dir_all(root.join(".mnote")).expect("metadata");
        fs::write(
            root.join(".mnote").join("workspace.json"),
            r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#,
        )
        .expect("manifest");
        let root_uri = format!("file://{}", root.display());

        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/client/sessions")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::from(
                        json!({
                            "workspaceId": "local-ws-user-1",
                            "documentId": "local-md:README.md",
                            "sourceKind": "local_folder",
                            "rootUri": root_uri,
                            "traceId": "trace_local_session_1",
                            "profile": "reasonix",
                            "title": "本地会话"
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["persistence"], "local_ai_session_jsonl");
        assert_eq!(payload["sessionStorage"], "local_private");
        let session_id = payload["sessionId"].as_str().expect("session id");
        let jsonl_path = root
            .join("ai-sessions")
            .join("private")
            .join(format!("{session_id}.jsonl"));
        let jsonl = fs::read_to_string(&jsonl_path).expect("jsonl");
        assert!(jsonl.contains("\"eventType\":\"session.created\""));
        assert!(jsonl.contains("\"title\":\"本地会话\""));

        let list_uri = format!(
            "/api/hermes/client/sessions?source=acp&sourceKind=local_folder&rootUri={}&workspaceId=local-ws-user-1&documentId=local-md%3AREADME.md",
            url_escape(&root_uri)
        );
        let list_response = app()
            .oneshot(
                Request::builder()
                    .uri(list_uri)
                    .header("x-mnote-actor-id", "user_1")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("list response");
        assert_eq!(list_response.status(), StatusCode::OK);
        let list_body = to_bytes(list_response.into_body(), usize::MAX)
            .await
            .expect("list body");
        let list_payload: Value = serde_json::from_slice(&list_body).expect("list json");
        assert_eq!(list_payload["persistence"], "local_ai_session_jsonl");
        assert_eq!(list_payload["sessions"][0]["sessionId"], session_id);

        let detail_uri = format!(
            "/api/hermes/client/sessions/{}?source=acp&sourceKind=local_folder&rootUri={}&workspaceId=local-ws-user-1",
            session_id,
            url_escape(&root_uri)
        );
        let detail_response = app()
            .oneshot(
                Request::builder()
                    .uri(detail_uri)
                    .header("x-mnote-actor-id", "user_1")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("detail response");
        assert_eq!(detail_response.status(), StatusCode::OK);
        let detail_body = to_bytes(detail_response.into_body(), usize::MAX)
            .await
            .expect("detail body");
        let detail_payload: Value = serde_json::from_slice(&detail_body).expect("detail json");
        assert_eq!(detail_payload["persistence"], "local_ai_session_jsonl");
        assert_eq!(detail_payload["events"][0]["eventType"], "session.created");

        let _ = fs::remove_dir_all(&root);
    }

    #[tokio::test]
    async fn hermes_client_local_acp_run_writes_private_jsonl_without_convex() {
        let root =
            std::env::temp_dir().join(format!("mnote-local-ai-run-private-{}", std::process::id()));
        let _ = fs::remove_dir_all(&root);
        fs::create_dir_all(root.join(".mnote")).expect("metadata");
        fs::write(
            root.join(".mnote").join("workspace.json"),
            r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#,
        )
        .expect("manifest");
        let root_uri = format!("file://{}", root.display());

        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/client/runs")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::from(
                        json!({
                            "workspaceId": "local-ws-user-1",
                            "documentId": "local-md:README.md",
                            "sessionId": "sess_local_run_1",
                            "sourceKind": "local_folder",
                            "rootUri": root_uri,
                            "traceId": "trace_local_run_1",
                            "profile": "reasonix",
                            "message": "本地运行"
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["persistence"], "local_ai_session_jsonl");
        assert_eq!(payload["sessionStorage"], "local_private");
        let jsonl_path = root
            .join("ai-sessions")
            .join("private")
            .join("sess_local_run_1.jsonl");
        let jsonl = fs::read_to_string(&jsonl_path).expect("jsonl");
        assert!(jsonl.contains("\"eventType\":\"run.started\""));
        assert!(jsonl.contains("\"persistence\":\"local_ai_session_jsonl\""));

        let _ = fs::remove_dir_all(&root);
    }

    #[tokio::test]
    async fn hermes_client_local_shared_write_session_writes_shared_jsonl_with_audit() {
        let _guard = env_lock().lock().expect("env lock");
        let root = std::env::temp_dir().join(format!(
            "mnote-local-ai-session-shared-{}",
            std::process::id()
        ));
        let config_root = std::env::temp_dir().join(format!(
            "mnote-local-ai-session-shared-config-{}",
            std::process::id()
        ));
        let _ = fs::remove_dir_all(&root);
        let _ = fs::remove_dir_all(&config_root);
        fs::create_dir_all(root.join(".mnote")).expect("metadata");
        fs::create_dir_all(&config_root).expect("config root");
        fs::write(
            root.join(".mnote").join("workspace.json"),
            r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#,
        )
        .expect("manifest");
        let root_uri = format!("file://{}", root.display());
        let share_grants_file = config_root.join("share-grants.json");
        fs::write(
            &share_grants_file,
            json!({
                "grants": [{
                    "id": "share_grant_1",
                    "shareId": "share_1",
                    "ownerUserId": "user_1",
                    "targetUserId": "user_1",
                    "rootUri": root_uri,
                    "documentId": "local-md:README.md",
                    "allowedResourceIds": ["local-md:README.md"],
                    "permission": "write",
                    "capabilities": ["ai", "share"],
                    "createdAt": "1",
                    "active": true
                }]
            })
            .to_string(),
        )
        .expect("share grants");
        std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file);

        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/client/sessions")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::from(
                        json!({
                            "workspaceId": "local-ws-user-1",
                            "documentId": "local-md:README.md",
                            "sourceKind": "local_folder",
                            "rootUri": root_uri,
                            "shareId": "share_1",
                            "permissionLevel": "shared_write",
                            "traceId": "trace_shared_session_1",
                            "profile": "reasonix",
                            "title": "共享会话"
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["sessionStorage"], "local_shared");
        let session_id = payload["sessionId"].as_str().expect("session id");
        let jsonl_path = root
            .join("ai-sessions")
            .join("shared")
            .join("share_1")
            .join(format!("{session_id}.jsonl"));
        let jsonl = fs::read_to_string(&jsonl_path).expect("jsonl");
        assert!(jsonl.contains("\"eventType\":\"session.created\""));
        assert!(jsonl.contains("\"eventType\":\"audit.shared_write\""));
        assert!(jsonl.contains("\"shareId\":\"share_1\""));
        assert!(jsonl.contains("\"allowedResourceIds\":[\"local-md:README.md\"]"));
        assert!(jsonl.contains("\"shareContext\""));

        std::env::remove_var("MNOTE_SHARE_GRANTS_FILE");
        let _ = fs::remove_dir_all(&root);
        let _ = fs::remove_dir_all(&config_root);
    }

    #[tokio::test]
    async fn hermes_client_local_shared_read_session_write_is_forbidden() {
        let _guard = env_lock().lock().expect("env lock");
        let root = std::env::temp_dir().join(format!(
            "mnote-local-ai-session-shared-read-{}",
            std::process::id()
        ));
        let config_root = std::env::temp_dir().join(format!(
            "mnote-local-ai-session-shared-read-config-{}",
            std::process::id()
        ));
        let _ = fs::remove_dir_all(&root);
        let _ = fs::remove_dir_all(&config_root);
        fs::create_dir_all(root.join(".mnote")).expect("metadata");
        fs::create_dir_all(&config_root).expect("config root");
        fs::write(
            root.join(".mnote").join("workspace.json"),
            r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#,
        )
        .expect("manifest");
        let root_uri = format!("file://{}", root.display());
        let share_grants_file = config_root.join("share-grants.json");
        fs::write(
            &share_grants_file,
            json!({
                "grants": [{
                    "id": "share_grant_read_1",
                    "shareId": "share_1",
                    "ownerUserId": "user_1",
                    "targetUserId": "user_1",
                    "rootUri": root_uri,
                    "documentId": "local-md:README.md",
                    "allowedResourceIds": ["local-md:README.md"],
                    "permission": "read",
                    "capabilities": ["ai", "share"],
                    "createdAt": "1",
                    "active": true
                }]
            })
            .to_string(),
        )
        .expect("share grants");
        std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file);

        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/client/sessions")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::from(
                        json!({
                            "workspaceId": "local-ws-user-1",
                            "documentId": "local-md:README.md",
                            "sourceKind": "local_folder",
                            "rootUri": root_uri,
                            "shareId": "share_1",
                            "permissionLevel": "shared_read",
                            "traceId": "trace_shared_read_1",
                            "profile": "reasonix",
                            "title": "共享只读会话"
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::FORBIDDEN);
        assert!(!root.join("ai-sessions").join("shared").exists());

        std::env::remove_var("MNOTE_SHARE_GRANTS_FILE");
        let _ = fs::remove_dir_all(&root);
        let _ = fs::remove_dir_all(&config_root);
    }

    #[tokio::test]
    async fn hermes_client_session_create_carries_agent_profile_without_storing_chat() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/client/sessions")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "workspaceId": "ws_1",
                            "documentId": "doc_1",
                            "traceId": "trace_1",
                            "title": "当前页问答",
                            "profile": "chemist"
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["sessionId"], "mnote_doc_1_trace_1");
        assert_eq!(payload["profile"], "chemist");
        assert!(payload.get("messages").is_none());
    }

    #[tokio::test]
    async fn hermes_client_acp_session_create_indexes_session_in_convex_store() {
        let captured_body = Arc::new(Mutex::new(Value::Null));
        let captured_for_route = Arc::clone(&captured_body);
        let mock = axum::Router::new().route(
            "/api/mutation",
            post(move |Json(body): Json<Value>| {
                let captured = Arc::clone(&captured_for_route);
                async move {
                    *captured.lock().expect("captured convex body") = body;
                    Json(json!({
                        "status": "success",
                        "value": {"ok": true, "created": true}
                    }))
                }
            }),
        );
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("listener");
        let convex_url = format!("http://{}", listener.local_addr().expect("addr"));
        tokio::spawn(async move {
            axum::serve(listener, mock).await.expect("mock convex");
        });

        let response = app_with_config(AppConfig {
            service_name: "mnote-web".into(),
            service_version: "0.1.0".into(),
            bind_addr: "127.0.0.1:0".into(),
            public_bind_addr: "127.0.0.1:3000".into(),
            legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
            enable_legacy_next_compat: true,
            enable_debug_shell_routes: false,
            enable_editor_actor: true,
            hermes_base_path: "/api/hermes".into(),
            compat_next_base_path: "/api/compat/next".into(),
            convex_url: Some(convex_url),
            convex_admin_key: Some("admin-demo".into()),
            allow_dev_fixtures: false,
            query_fixtures_json: None,
            mutation_fixtures_json: None,
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        })
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/hermes/client/sessions")
                .header("content-type", "application/json")
                .header("x-mnote-actor-id", "user_1")
                .body(Body::from(
                    json!({
                        "workspaceId": "ws_1",
                        "documentId": "doc_1",
                        "traceId": "trace_1",
                        "title": "当前页问答",
                        "profile": "reasonix"
                    })
                    .to_string(),
                ))
                .expect("request"),
        )
        .await
        .expect("response");
        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);

        assert_eq!(
            captured_body.lock().expect("captured convex body").clone(),
            Value::Null
        );
    }

    #[tokio::test]
    async fn hermes_client_acp_run_registers_scoped_runtime_record_in_sqlite() {
        let _guard = runtime_lock().lock().expect("runtime lock");
        clear_runtime_registry();
        clear_run_queue();
        let router = app();

        let response = router
            .clone()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/client/runs")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .header("x-mnote-workspace-id", "ws_header")
                    .body(Body::from(
                        json!({
                            "workspaceId": "ws_1",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "message": "读取当前页面",
                            "profile": "reasonix",
                            "acpRuntime": "reasonix",
                            "runId": "run_trace_1",
                            "traceId": "trace_1",
                            "pageContext": {"title": "页面标题"}
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);

        let response = router
            .oneshot(
                Request::builder()
                    .method("GET")
                    .uri("/api/hermes/client/sessions?profile=reasonix&workspaceId=ws_1&documentId=doc_1")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("list response");
        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("list body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
        let run = &payload["sessions"][0];
        assert_eq!(run["sessionId"], "sess_1");
        assert_eq!(run["runId"], "run_trace_1");
        assert_eq!(run["workspaceId"], "ws_1");
        assert_eq!(run["documentId"], "doc_1");
        assert_eq!(run["profile"], "reasonix");
        assert_eq!(run["acpRuntime"], "reasonix");
        assert_eq!(run["runtime"]["status"], "acp_pending");
        assert_eq!(run["payload"]["message"], "读取当前页面");
        assert!(run.get("messages").is_none());
    }

    #[tokio::test]
    async fn hermes_client_acp_session_list_reads_current_user_convex_store() {
        let captured_body = Arc::new(Mutex::new(Value::Null));
        let captured_for_route = Arc::clone(&captured_body);
        let mock = axum::Router::new().route(
            "/api/query",
            get(|| async { StatusCode::METHOD_NOT_ALLOWED }).post(
                move |Json(body): Json<Value>| {
                    let captured = Arc::clone(&captured_for_route);
                    async move {
                        *captured.lock().expect("captured convex body") = body;
                        Json(json!({
                            "status": "success",
                            "value": [
                                {
                                    "sessionId": "sess_1",
                                    "runId": "run_1",
                                    "workspaceId": "ws_1",
                                    "documentId": "doc_1",
                                    "profile": "reasonix",
                                    "status": "completed"
                                }
                            ]
                        }))
                    }
                },
            ),
        );
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("listener");
        let convex_url = format!("http://{}", listener.local_addr().expect("addr"));
        tokio::spawn(async move {
            axum::serve(listener, mock).await.expect("mock convex");
        });

        let response = app_with_config(AppConfig {
            service_name: "mnote-web".into(),
            service_version: "0.1.0".into(),
            bind_addr: "127.0.0.1:0".into(),
            public_bind_addr: "127.0.0.1:3000".into(),
            legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
            enable_legacy_next_compat: true,
            enable_debug_shell_routes: false,
            enable_editor_actor: true,
            hermes_base_path: "/api/hermes".into(),
            compat_next_base_path: "/api/compat/next".into(),
            convex_url: Some(convex_url),
            convex_admin_key: Some("admin-demo".into()),
            allow_dev_fixtures: false,
            query_fixtures_json: None,
            mutation_fixtures_json: None,
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        })
        .oneshot(
            Request::builder()
                .method("GET")
                .uri("/api/hermes/client/sessions?profile=reasonix&workspaceId=ws_1&documentId=doc_1&legacyConvex=1")
                .header("x-mnote-actor-id", "user_1")
                .body(Body::empty())
                .expect("request"),
        )
        .await
        .expect("response");
        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["ok"], true);
        assert_eq!(payload["sessions"][0]["sessionId"], "sess_1");
        assert_eq!(payload["persistence"], "convex_acp_runtime_store");

        let query_body = captured_body.lock().expect("captured convex body").clone();
        assert_eq!(query_body["path"], "aiSessions:listRuntimeRuns");
        assert_eq!(query_body["args"]["userId"], "user_1");
        assert_eq!(query_body["args"]["workspaceId"], "ws_1");
        assert_eq!(query_body["args"]["documentId"], "doc_1");
    }

    #[tokio::test]
    async fn hermes_client_acp_session_detail_reads_runtime_runs_and_events() {
        let captured_bodies = Arc::new(Mutex::new(Vec::<Value>::new()));
        let captured_for_route = Arc::clone(&captured_bodies);
        let mock = axum::Router::new().route(
            "/api/query",
            post(move |Json(body): Json<Value>| {
                let captured = Arc::clone(&captured_for_route);
                async move {
                    captured
                        .lock()
                        .expect("captured convex bodies")
                        .push(body.clone());
                    let path = body["path"].as_str().unwrap_or_default();
                    let value = match path {
                        "aiSessions:listRuntimeRuns" => json!([
                            {
                                "sessionId": "sess_1",
                                "runId": "run_1",
                                "workspaceId": "ws_1",
                                "documentId": "doc_1",
                                "profile": "reasonix",
                                "status": "completed",
                                "runtime": {"status": "completed", "runId": "run_1"}
                            }
                        ]),
                        "aiSessions:listRuntimeEvents" => json!([
                            {
                                "eventType": "message.delta",
                                "payload": {"text": "hello"}
                            }
                        ]),
                        _ => Value::Null,
                    };
                    Json(json!({"status": "success", "value": value}))
                }
            }),
        );
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("listener");
        let convex_url = format!("http://{}", listener.local_addr().expect("addr"));
        tokio::spawn(async move {
            axum::serve(listener, mock).await.expect("mock convex");
        });

        let response = app_with_config(AppConfig {
            service_name: "mnote-web".into(),
            service_version: "0.1.0".into(),
            bind_addr: "127.0.0.1:0".into(),
            public_bind_addr: "127.0.0.1:3000".into(),
            legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
            enable_legacy_next_compat: true,
            enable_debug_shell_routes: false,
            enable_editor_actor: true,
            hermes_base_path: "/api/hermes".into(),
            compat_next_base_path: "/api/compat/next".into(),
            convex_url: Some(convex_url),
            convex_admin_key: Some("admin-demo".into()),
            allow_dev_fixtures: false,
            query_fixtures_json: None,
            mutation_fixtures_json: None,
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        })
        .oneshot(
            Request::builder()
                .method("GET")
                .uri(
                    "/api/hermes/client/sessions/sess_1?source=acp&workspaceId=ws_1&legacyConvex=1",
                )
                .header("x-mnote-actor-id", "user_1")
                .body(Body::empty())
                .expect("request"),
        )
        .await
        .expect("response");
        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["ok"], true);
        assert_eq!(payload["persistence"], "convex_acp_runtime_store");
        assert_eq!(payload["session"]["sessionId"], "sess_1");
        assert_eq!(payload["session"]["runs"][0]["runId"], "run_1");
        assert_eq!(payload["runtime"]["runId"], "run_1");
        assert_eq!(payload["events"][0]["eventType"], "message.delta");
        assert_eq!(
            payload["session"]["messages"].as_array().map(Vec::len),
            Some(0)
        );

        let bodies = captured_bodies.lock().expect("captured convex bodies");
        assert_eq!(bodies[0]["path"], "aiSessions:listRuntimeRuns");
        assert_eq!(bodies[0]["args"]["userId"], "user_1");
        assert_eq!(bodies[0]["args"]["sessionId"], "sess_1");
        assert_eq!(bodies[1]["path"], "aiSessions:listRuntimeEvents");
        assert_eq!(bodies[1]["args"]["runId"], "run_1");
    }

    #[tokio::test]
    async fn hermes_client_acp_resume_marks_convex_runtime_history_source() {
        let mock = axum::Router::new().route(
            "/api/query",
            post(|Json(body): Json<Value>| async move {
                let path = body["path"].as_str().unwrap_or_default();
                let value = match path {
                    "aiSessions:listRuntimeRuns" => json!([
                        {
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "workspaceId": "ws_1",
                            "documentId": "doc_1",
                            "profile": "reasonix",
                            "status": "completed",
                            "runtime": {"status": "completed", "runId": "run_1"}
                        }
                    ]),
                    "aiSessions:listRuntimeEvents" => json!([]),
                    _ => Value::Null,
                };
                Json(json!({"status": "success", "value": value}))
            }),
        );
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("listener");
        let convex_url = format!("http://{}", listener.local_addr().expect("addr"));
        tokio::spawn(async move {
            axum::serve(listener, mock).await.expect("mock convex");
        });

        let response = app_with_config(AppConfig {
            service_name: "mnote-web".into(),
            service_version: "0.1.0".into(),
            bind_addr: "127.0.0.1:0".into(),
            public_bind_addr: "127.0.0.1:3000".into(),
            legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
            enable_legacy_next_compat: true,
            enable_debug_shell_routes: false,
            enable_editor_actor: true,
            hermes_base_path: "/api/hermes".into(),
            compat_next_base_path: "/api/compat/next".into(),
            convex_url: Some(convex_url),
            convex_admin_key: Some("admin-demo".into()),
            allow_dev_fixtures: false,
            query_fixtures_json: None,
            mutation_fixtures_json: None,
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        })
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/hermes/client/sessions/sess_1/resume?source=acp&workspaceId=ws_1&legacyConvex=1")
                .header("x-mnote-actor-id", "user_1")
                .body(Body::empty())
                .expect("request"),
        )
        .await
        .expect("response");
        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["ok"], true);
        assert_eq!(payload["resumed"], true);
        assert_eq!(payload["resumeSource"], "convex_acp_runtime_store");
        assert_eq!(payload["session"]["runs"][0]["runId"], "run_1");
    }

    #[tokio::test]
    async fn hermes_client_acp_session_rename_uses_sqlite_store() {
        let response = build_app(seeded_acp_state())
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/client/sessions/sess_1/rename?source=acp&workspaceId=ws_1")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(json!({"title": "新标题"}).to_string()))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["ok"], true);
        assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
        assert_eq!(payload["result"]["title"], "新标题");
        assert_eq!(payload["result"]["runs"][0]["sessionId"], "sess_1");
    }

    #[tokio::test]
    async fn hermes_client_acp_session_delete_and_auto_title_use_sqlite_store() {
        let router = build_app(seeded_acp_state());

        let response = router
            .clone()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri(
                        "/api/hermes/client/sessions/sess_1/auto-title?source=acp&workspaceId=ws_1",
                    )
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
        assert_eq!(payload["result"]["title"], "自动标题");

        let response = router
            .oneshot(
                Request::builder()
                    .method("DELETE")
                    .uri("/api/hermes/client/sessions/sess_1?source=acp&workspaceId=ws_1")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("delete body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
        assert_eq!(payload["result"]["deleted"], 1);
    }

    #[tokio::test]
    async fn hermes_client_acp_session_search_returns_convex_snippets() {
        let captured_body = Arc::new(Mutex::new(Value::Null));
        let captured_for_route = Arc::clone(&captured_body);
        let mock = axum::Router::new().route(
            "/api/query",
            post(move |Json(body): Json<Value>| {
                let captured = Arc::clone(&captured_for_route);
                async move {
                    *captured.lock().expect("captured convex body") = body;
                    Json(json!({
                        "status": "success",
                        "value": [
                            {
                                "sessionId": "sess_1",
                                "runId": "run_1",
                                "title": "化学总结",
                                "snippet": "帮我总结化学页面",
                                "score": 1
                            }
                        ]
                    }))
                }
            }),
        );
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("listener");
        let convex_url = format!("http://{}", listener.local_addr().expect("addr"));
        tokio::spawn(async move {
            axum::serve(listener, mock).await.expect("mock convex");
        });

        let response = app_with_config(AppConfig {
            service_name: "mnote-web".into(),
            service_version: "0.1.0".into(),
            bind_addr: "127.0.0.1:0".into(),
            public_bind_addr: "127.0.0.1:3000".into(),
            legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
            enable_legacy_next_compat: true,
            enable_debug_shell_routes: false,
            enable_editor_actor: true,
            hermes_base_path: "/api/hermes".into(),
            compat_next_base_path: "/api/compat/next".into(),
            convex_url: Some(convex_url),
            convex_admin_key: Some("admin-demo".into()),
            allow_dev_fixtures: false,
            query_fixtures_json: None,
            mutation_fixtures_json: None,
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        })
        .oneshot(
            Request::builder()
                .method("GET")
                .uri("/api/hermes/client/sessions/search?source=acp&workspaceId=ws_1&q=%E5%8C%96%E5%AD%A6&limit=5&legacyConvex=1")
                .header("x-mnote-actor-id", "user_1")
                .body(Body::empty())
                .expect("request"),
        )
        .await
        .expect("response");
        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["ok"], true);
        assert_eq!(payload["results"][0]["sessionId"], "sess_1");
        assert_eq!(payload["results"][0]["snippet"], "帮我总结化学页面");

        let query_body = captured_body.lock().expect("captured convex body").clone();
        assert_eq!(query_body["path"], "aiSessions:searchRuntimeSessions");
        assert_eq!(query_body["args"]["userId"], "user_1");
        assert_eq!(query_body["args"]["workspaceId"], "ws_1");
        assert_eq!(query_body["args"]["q"], "化学");
        assert_eq!(query_body["args"]["limit"], 5);
    }

    #[tokio::test]
    async fn hermes_client_resume_returns_runtime_state_without_chat_store() {
        let _guard = runtime_lock().lock().expect("runtime lock");
        clear_runtime_registry();
        clear_run_queue();
        let registration = HermesRunRegistration {
            session_id: "mnote_doc_1_trace_1".into(),
            profile: "chemist".into(),
            document_id: "doc_1".into(),
            trace_id: "trace_1".into(),
        };
        let runtime = register_runtime_from_create_run_response(
            &registration,
            &json!({
                "ok": true,
                "upstream": {"run_id": "run_1"}
            }),
        )
        .expect("runtime");
        assert_eq!(runtime["sessionId"], "mnote_doc_1_trace_1");
        assert_eq!(runtime["runId"], "run_1");
        assert!(runtime.get("messages").is_none());

        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/client/sessions/mnote_doc_1_trace_1/resume")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["ok"], true);
        assert_eq!(
            payload["session"]["messages"].as_array().map(Vec::len),
            Some(0)
        );
        assert_eq!(payload["runtime"]["sessionId"], "mnote_doc_1_trace_1");
        assert_eq!(payload["runtime"]["runId"], "run_1");
        assert_eq!(payload["runtime"]["status"], "running");
    }

    #[test]
    fn hermes_client_active_session_queues_second_run_without_chat_truth() {
        let _guard = runtime_lock().lock().expect("runtime lock");
        clear_runtime_registry();
        clear_run_queue();
        let context = RequestContext::from_http_parts(
            &axum::http::Method::POST,
            &"/api/hermes/client/runs".parse().expect("uri"),
            &HeaderMap::new(),
        );
        let registration = HermesRunRegistration {
            session_id: "mnote_doc_queue_trace_1".into(),
            profile: "chemist".into(),
            document_id: "doc_queue".into(),
            trace_id: "trace_queued_1".into(),
        };
        register_runtime_from_create_run_response(
            &registration,
            &json!({
                "ok": true,
                "upstream": {"run_id": "run_active_1"}
            }),
        )
        .expect("runtime");
        assert!(session_has_active_run("mnote_doc_queue_trace_1"));

        let queued = enqueue_run(
            &context,
            &registration,
            &json!({
                "workspaceId": "ws_1",
                "documentId": "doc_queue",
                "sessionId": "mnote_doc_queue_trace_1",
                "profile": "chemist",
                "message": "第二条问题",
                "traceId": "trace_queued_1",
                "selectedText": "选区",
                "pageContext": {
                    "title": "页面标题",
                    "body": "正文不应进入 queue 响应",
                    "blocks": [{"id": "block_1"}]
                },
                "messages": [
                    {"role": "assistant", "content": "历史消息不应保存为 mnote 真相"}
                ]
            }),
        )
        .expect("queued");
        assert_eq!(queued["queued"], true);
        assert_eq!(queued["queueLength"], 1);
        assert_eq!(queued["sessionId"], "mnote_doc_queue_trace_1");
        assert!(queued.get("messages").is_none());
        assert!(queued.get("pageContext").is_none());
        assert_eq!(queued["contextSummary"]["hasPageContext"], true);
        assert_eq!(queued["contextSummary"]["hasSelectedText"], true);

        let popped = pop_next_queued_run("mnote_doc_queue_trace_1").expect("queued run");
        assert_eq!(popped.input, "第二条问题");
        assert_eq!(popped.profile, "chemist");
        assert!(popped.context_summary.get("pageContext").is_none());
        assert_eq!(queue_len_for_session("mnote_doc_queue_trace_1"), 0);
    }

    #[test]
    fn hermes_client_acp_stream_payload_lookup_keeps_payload_for_reconnect() {
        let _guard = runtime_lock().lock().expect("runtime lock");
        clear_acp_run_payloads();
        ACP_RUN_PAYLOADS
            .lock()
            .expect("acp run payloads")
            .insert("run_keep_1".into(), json!({"message": "hello"}));

        let first = acp_run_payload_for_stream("run_keep_1").expect("first payload");
        let second = acp_run_payload_for_stream("run_keep_1").expect("second payload");

        assert_eq!(first["message"], "hello");
        assert_eq!(second["message"], "hello");
    }

    #[test]
    fn hermes_client_acp_runtime_event_store_args_are_user_scoped() {
        let mut headers = HeaderMap::new();
        headers.insert("x-mnote-actor-id", "user_1".parse().unwrap());
        headers.insert("x-mnote-workspace-id", "ws_header".parse().unwrap());
        let context = RequestContext::from_http_parts(
            &axum::http::Method::GET,
            &"/api/hermes/client/events/run_1".parse().expect("uri"),
            &headers,
        );
        let registration = HermesRunRegistration {
            session_id: "sess_1".into(),
            profile: "reasonix".into(),
            document_id: "doc_1".into(),
            trace_id: "trace_1".into(),
        };
        let args = acp_runtime_event_store_args(
            &context,
            &registration,
            "run_1",
            "reasonix",
            "message.delta",
            &json!({"text": "hello"}),
            &json!({"workspaceId": "ws_1"}),
        );

        assert_eq!(args["schema"], "mnote.acp_runtime_event.v1");
        assert_eq!(args["userId"], "user_1");
        assert_eq!(args["workspaceId"], "ws_1");
        assert_eq!(args["documentId"], "doc_1");
        assert_eq!(args["sessionId"], "sess_1");
        assert_eq!(args["runId"], "run_1");
        assert_eq!(args["profile"], "reasonix");
        assert_eq!(args["acpRuntime"], "reasonix");
        assert_eq!(args["eventType"], "message.delta");
        assert_eq!(args["payload"]["text"], "hello");
        assert!(args.get("messages").is_none());
    }

    #[test]
    fn hermes_client_cancel_queued_run_removes_pending_item() {
        let _guard = runtime_lock().lock().expect("runtime lock");
        clear_runtime_registry();
        clear_run_queue();
        let context = RequestContext::from_http_parts(
            &axum::http::Method::POST,
            &"/api/hermes/client/runs".parse().expect("uri"),
            &HeaderMap::new(),
        );
        let registration = HermesRunRegistration {
            session_id: "mnote_doc_cancel_trace_1".into(),
            profile: "default".into(),
            document_id: "doc_cancel".into(),
            trace_id: "trace_cancel_1".into(),
        };
        let queued = enqueue_run(
            &context,
            &registration,
            &json!({
                "documentId": "doc_cancel",
                "sessionId": "mnote_doc_cancel_trace_1",
                "message": "等待取消",
                "traceId": "trace_cancel_1"
            }),
        )
        .expect("queued");
        let queue_id = queued["queueId"].as_str().expect("queue id");
        assert_eq!(queue_len_for_session("mnote_doc_cancel_trace_1"), 1);
        assert!(remove_queued_run("mnote_doc_cancel_trace_1", queue_id));
        assert_eq!(queue_len_for_session("mnote_doc_cancel_trace_1"), 0);
        assert!(pop_next_queued_run("mnote_doc_cancel_trace_1").is_none());
    }

    #[tokio::test]
    async fn hermes_client_terminal_run_can_start_next_queued_run() {
        let _env_guard = env_lock().lock().expect("env lock");
        let _runtime_guard = runtime_lock().lock().expect("runtime lock");
        clear_runtime_registry();
        clear_run_queue();

        let hits = Arc::new(AtomicUsize::new(0));
        let hits_for_route = Arc::clone(&hits);
        let mock = axum::Router::new().route(
            "/v1/runs",
            post(move |Json(_body): Json<Value>| {
                let hits = Arc::clone(&hits_for_route);
                async move {
                    hits.fetch_add(1, Ordering::SeqCst);
                    Json(json!({
                        "run_id": "run_queued_next",
                        "trace_id": "trace_queued_next"
                    }))
                }
            }),
        );
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("listener");
        let upstream = format!("http://{}", listener.local_addr().expect("addr"));
        tokio::spawn(async move {
            axum::serve(listener, mock).await.expect("mock upstream");
        });
        std::env::set_var("MNOTE_WEB_HERMES_UPSTREAM_URL", &upstream);
        std::env::remove_var("MNOTE_HERMES_UPSTREAM_URL");

        let context = RequestContext::from_http_parts(
            &axum::http::Method::POST,
            &"/api/hermes/client/runs".parse().expect("uri"),
            &HeaderMap::new(),
        );
        let registration = HermesRunRegistration {
            session_id: "mnote_doc_auto_trace_1".into(),
            profile: "chemist".into(),
            document_id: "doc_auto".into(),
            trace_id: "trace_auto_1".into(),
        };
        register_runtime_from_create_run_response(
            &registration,
            &json!({
                "ok": true,
                "upstream": {"run_id": "run_active_auto"}
            }),
        )
        .expect("runtime");
        enqueue_run(
            &context,
            &registration,
            &json!({
                "workspaceId": "ws_1",
                "documentId": "doc_auto",
                "sessionId": "mnote_doc_auto_trace_1",
                "profile": "chemist",
                "message": "自动出队问题",
                "traceId": "trace_auto_1",
                "pageContext": {"title": "自动出队"}
            }),
        )
        .expect("queued");

        let (_, terminal_sessions) = normalize_sse_chunk(
            "run_active_auto",
            &format!(
                "data: {}\n\n",
                json!({
                    "event": "run.completed",
                    "run_id": "run_active_auto",
                    "session_id": "mnote_doc_auto_trace_1"
                })
            ),
        );
        assert_eq!(
            terminal_sessions,
            vec!["mnote_doc_auto_trace_1".to_string()]
        );

        start_next_queued_run(context, "mnote_doc_auto_trace_1".into()).await;
        assert_eq!(hits.load(Ordering::SeqCst), 1);
        assert_eq!(queue_len_for_session("mnote_doc_auto_trace_1"), 0);
        let runtime = runtime_state_for_session("mnote_doc_auto_trace_1");
        assert_eq!(runtime["runId"], "run_queued_next");
        assert_eq!(runtime["status"], "running");

        std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL");
    }

    #[tokio::test]
    async fn hermes_client_abort_returns_events_and_continues_queue() {
        let _env_guard = env_lock().lock().expect("env lock");
        let _runtime_guard = runtime_lock().lock().expect("runtime lock");
        clear_runtime_registry();
        clear_run_queue();
        std::env::set_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY", "1");

        let stop_hits = Arc::new(AtomicUsize::new(0));
        let run_hits = Arc::new(AtomicUsize::new(0));
        let stop_hits_for_route = Arc::clone(&stop_hits);
        let run_hits_for_route = Arc::clone(&run_hits);
        let mock = axum::Router::new()
            .route(
                "/v1/runs/{run_id}/stop",
                post(move || {
                    let stop_hits = Arc::clone(&stop_hits_for_route);
                    async move {
                        stop_hits.fetch_add(1, Ordering::SeqCst);
                        Json(json!({"ok": true, "status": "aborted"}))
                    }
                }),
            )
            .route(
                "/v1/runs",
                post(move |Json(_body): Json<Value>| {
                    let run_hits = Arc::clone(&run_hits_for_route);
                    async move {
                        run_hits.fetch_add(1, Ordering::SeqCst);
                        Json(json!({
                            "run_id": "run_after_abort",
                            "trace_id": "trace_after_abort"
                        }))
                    }
                }),
            );
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("listener");
        let upstream = format!("http://{}", listener.local_addr().expect("addr"));
        tokio::spawn(async move {
            axum::serve(listener, mock).await.expect("mock upstream");
        });
        std::env::set_var("MNOTE_WEB_HERMES_UPSTREAM_URL", &upstream);
        std::env::remove_var("MNOTE_HERMES_UPSTREAM_URL");

        let context = RequestContext::from_http_parts(
            &axum::http::Method::POST,
            &"/api/hermes/client/runs/run_abort_1/abort"
                .parse()
                .expect("uri"),
            &HeaderMap::new(),
        );
        let registration = HermesRunRegistration {
            session_id: "mnote_doc_abort_trace_1".into(),
            profile: "default".into(),
            document_id: "doc_abort".into(),
            trace_id: "trace_abort_1".into(),
        };
        register_runtime_from_create_run_response(
            &registration,
            &json!({
                "ok": true,
                "upstream": {"run_id": "run_abort_1"}
            }),
        )
        .expect("runtime");
        enqueue_run(
            &context,
            &registration,
            &json!({
                "documentId": "doc_abort",
                "sessionId": "mnote_doc_abort_trace_1",
                "message": "abort 后继续",
                "traceId": "trace_abort_1"
            }),
        )
        .expect("queued");

        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/client/runs/run_abort_1/abort")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(json!({"reason":"test_abort"}).to_string()))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["events"][0]["event"], "abort.started");
        assert_eq!(payload["events"][1]["event"], "abort.completed");
        assert_eq!(stop_hits.load(Ordering::SeqCst), 1);
        assert_eq!(run_hits.load(Ordering::SeqCst), 1);
        assert_eq!(queue_len_for_session("mnote_doc_abort_trace_1"), 0);
        let runtime = runtime_state_for_session("mnote_doc_abort_trace_1");
        assert_eq!(runtime["runId"], "run_after_abort");
        assert_eq!(runtime["status"], "running");

        std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL");
        std::env::remove_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY");
    }

    #[test]
    fn hermes_client_runtime_registry_tracks_tool_event_summary_only() {
        let _guard = runtime_lock().lock().expect("runtime lock");
        clear_runtime_registry();
        clear_run_queue();
        let registration = HermesRunRegistration {
            session_id: "mnote_doc_2_trace_2".into(),
            profile: "default".into(),
            document_id: "doc_2".into(),
            trace_id: "trace_2".into(),
        };
        register_runtime_from_create_run_response(
            &registration,
            &json!({
                "ok": true,
                "runId": "run_2"
            }),
        )
        .expect("runtime");
        let _ = normalize_sse_chunk(
            "run_2",
            &format!(
                "data: {}\n\n",
                json!({
                    "event": "tool.started",
                    "run_id": "run_2",
                    "session_id": "mnote_doc_2_trace_2",
                    "name": "mnote.page.get",
                    "toolCallId": "call_1",
                    "arguments": {"includeBody": false}
                })
            ),
        );
        let runtime = runtime_state_for_session("mnote_doc_2_trace_2");
        assert_eq!(runtime["status"], "tool_calling");
        assert_eq!(runtime["lastToolName"], "mnote.page.get");
        assert_eq!(runtime["lastToolCallId"], "call_1");
        assert!(runtime.get("arguments").is_none());
        assert!(runtime.get("messages").is_none());
    }

    #[test]
    fn hermes_client_normalizes_upstream_sse_events_to_runtime_contract() {
        let _guard = runtime_lock().lock().expect("runtime lock");
        clear_runtime_registry();
        clear_run_queue();
        let registration = HermesRunRegistration {
            session_id: "mnote_doc_norm_trace_1".into(),
            profile: "default".into(),
            document_id: "doc_norm".into(),
            trace_id: "trace_norm_1".into(),
        };
        register_runtime_from_create_run_response(
            &registration,
            &json!({
                "ok": true,
                "runId": "run_norm_1"
            }),
        )
        .expect("runtime");

        let (normalized, terminal_sessions) = normalize_sse_chunk(
            "run_norm_1",
            &format!(
                "data: {}\n\ndata: {}\n\n",
                json!({
                    "event": "assistant_message",
                    "run_id": "run_norm_1",
                    "session_id": "mnote_doc_norm_trace_1",
                    "text": "hello"
                }),
                json!({
                    "event": "tool.failed",
                    "run_id": "run_norm_1",
                    "session_id": "mnote_doc_norm_trace_1",
                    "toolCallId": "call_failed_1",
                    "name": "mnote.page.save",
                    "code": "permission_denied",
                    "error": "写入被拒绝"
                })
            ),
        );
        assert!(normalized.contains("event: message.delta"));
        assert!(normalized.contains("\"schemaVersion\":\"mnote.hermes_runtime_event.v1\""));
        assert!(normalized.contains("\"event\":\"tool.failed\""));
        assert!(normalized.contains("\"toolCallId\":\"call_failed_1\""));
        assert!(normalized.contains("\"code\":\"permission_denied\""));
        assert!(terminal_sessions.is_empty());
    }

    #[test]
    fn hermes_client_run_body_carries_minimal_page_context_into_run_input() {
        let context = RequestContext::from_http_parts(
            &axum::http::Method::POST,
            &"/api/hermes/client/runs".parse().expect("uri"),
            &HeaderMap::new(),
        );
        let body = build_run_upstream_body(
            &context,
            json!({
                "workspaceId": "ws_1",
                "documentId": "doc_1",
                "sessionId": "sess_1",
                "message": "概括当前页面",
                "pageContext": {
                    "contextScope": "page",
                    "node": {"documentId": "doc_1", "title": "页面标题"},
                    "documentBlocks": [{"type": "paragraph", "text": "不应进入 Hermes instructions"}],
                    "subtree": {"children": [{"title": "不应进入 Hermes instructions"}]},
                    "outline": [{"title": "不应进入 Hermes instructions"}],
                    "contentAccess": "mnote.page.get",
                    "aiContext": {
                        "schema": "mnote.page_ai_context.v1",
                        "contextBlocks": [{"blockId": "block_1", "text": "允许进入 Hermes instructions"}],
                        "pageXml": "<page><block id=\"block_1\">允许进入 Hermes instructions</block></page>",
                        "allowedTargetBlockIds": ["block_1"]
                    }
                },
                "selectedBlockId": "block_1",
                "selectedText": "选中文本",
                "traceId": "trace_1"
            }),
        )
        .expect("body");
        assert_eq!(body["input"], "概括当前页面");
        assert_eq!(body["session_id"], "sess_1");
        let instructions = body["instructions"].as_str().expect("instructions");
        assert!(instructions.contains("\"workspaceId\":\"ws_1\""));
        assert!(instructions.contains("\"documentId\":\"doc_1\""));
        assert!(instructions.contains("\"title\":\"页面标题\""));
        assert!(instructions.contains("\"selectedBlockId\":\"block_1\""));
        assert!(instructions.contains("\"contentAccess\":\"mnote.page.get\""));
        assert!(instructions.contains("\"schema\":\"mnote.page_ai_context.v1\""));
        assert!(instructions.contains("允许进入 Hermes instructions"));
        assert!(!instructions.contains("不应进入 Hermes instructions"));
        assert!(!instructions.contains("\"documentBlocks\""));
        assert!(!instructions.contains("\"subtree\""));
        assert!(!instructions.contains("\"outline\""));
    }

    #[test]
    fn hermes_client_run_body_local_source_uses_file_scope_not_full_page_context() {
        let mut headers = HeaderMap::new();
        headers.insert("x-mnote-actor-id", "user_1".parse().unwrap());
        headers.insert("x-mnote-actor-type", "user".parse().unwrap());
        let context = RequestContext::from_http_parts(
            &axum::http::Method::POST,
            &"/api/hermes/client/runs".parse().expect("uri"),
            &headers,
        );
        let body = build_run_upstream_body(
            &context,
            json!({
                "workspaceId": "local-workspace-1",
                "documentId": "local-md:README.md",
                "sessionId": "sess_local_1",
                "sourceKind": "local_folder",
                "rootUri": "file:///mnt/Data1T/Mnote_data/users/user_1/我的空间",
                "message": "把选中的句子改短",
                "selectedText": "选中的句子",
                "pageContext": {
                    "node": {"documentId": "local-md:README.md", "title": "README"},
                    "aiContext": {
                        "schema": "mnote.page_ai_context.v1",
                        "pageText": "完整页面正文不应进入本地 agent instructions",
                        "pageXml": "<page>完整页面 XML 不应进入本地 agent instructions</page>",
                        "contextBlocks": [{"blockId": "block_1", "text": "完整块内容不应进入本地 agent instructions"}],
                        "allowedTargetBlockIds": ["block_1"]
                    }
                },
                "traceId": "trace_local_1"
            }),
        )
        .expect("body");

        let instructions = body["instructions"].as_str().expect("instructions");
        assert!(instructions.contains("\"sourceKind\":\"local_folder\""));
        assert!(instructions.contains("\"fileReference\""));
        assert!(instructions
            .contains("\"rootUri\":\"file:///mnt/Data1T/Mnote_data/users/user_1/我的空间\""));
        assert!(instructions.contains("\"aiAccessScope\""));
        assert!(instructions.contains("\"allowedRoots\""));
        assert!(instructions.contains("\"selectedText\":\"选中的句子\""));
        assert!(!instructions.contains("完整页面正文不应进入本地 agent instructions"));
        assert!(!instructions.contains("完整页面 XML 不应进入本地 agent instructions"));
        assert!(!instructions.contains("完整块内容不应进入本地 agent instructions"));
        assert!(!instructions.contains("\"contextBlocks\""));
        assert!(!instructions.contains("\"pageXml\""));
        assert!(!instructions.contains("\"pageText\""));
    }

    #[test]
    fn hermes_client_run_body_shared_scope_uses_share_grant_resources() {
        let _guard = env_lock().lock().expect("env lock");
        let config_root = std::env::temp_dir().join(format!(
            "mnote-shared-run-body-config-{}",
            std::process::id()
        ));
        let _ = fs::remove_dir_all(&config_root);
        fs::create_dir_all(&config_root).expect("config root");
        let share_grants_file = config_root.join("share-grants.json");
        fs::write(
            &share_grants_file,
            json!({
                "grants": [{
                    "id": "share_grant_scope_1",
                    "shareId": "share_scope_1",
                    "ownerUserId": "owner_1",
                    "targetUserId": "target_1",
                    "rootUri": "file:///mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space",
                    "documentId": "local-md:docs~2FShared.md",
                    "allowedResourceIds": ["local-md:docs~2FShared.md"],
                    "permission": "write",
                    "capabilities": ["ai", "share"],
                    "createdAt": "1",
                    "active": true
                }]
            })
            .to_string(),
        )
        .expect("share grants");
        std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file);
        let mut headers = HeaderMap::new();
        headers.insert("x-mnote-actor-id", "target_1".parse().unwrap());
        headers.insert("x-mnote-actor-type", "user".parse().unwrap());
        let context = RequestContext::from_http_parts(
            &axum::http::Method::POST,
            &"/api/hermes/client/runs".parse().expect("uri"),
            &headers,
        );
        let body = build_run_upstream_body(
            &context,
            json!({
                "workspaceId": "local-workspace-shared",
                "documentId": "local-md:docs~2FShared.md",
                "sessionId": "sess_shared_scope_1",
                "sourceKind": "local_folder",
                "rootUri": "file:///mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space",
                "shareId": "share_scope_1",
                "actorId": "target_1",
                "message": "编辑共享页面",
                "traceId": "trace_shared_scope_1"
            }),
        )
        .expect("body");

        let instructions = body["instructions"].as_str().expect("instructions");
        assert!(instructions.contains("\"permissionLevel\":\"shared_write\""));
        assert!(instructions.contains("\"shareContext\""));
        assert!(instructions.contains("\"shareId\":\"share_scope_1\""));
        assert!(instructions.contains("\"allowedResourceIds\":[\"local-md:docs~2FShared.md\"]"));
        assert!(instructions.contains(
            "\"allowedFilePaths\":[\"/mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space/docs/Shared.md\"]"
        ));

        std::env::remove_var("MNOTE_SHARE_GRANTS_FILE");
        let _ = fs::remove_dir_all(&config_root);
    }

    #[test]
    fn acp_runtime_env_limits_local_source_to_allowed_root() {
        let payload = json!({
            "workspaceId": "local-workspace-1",
            "documentId": "local-md:README.md",
            "sessionId": "sess_local_1",
            "sourceKind": "local_folder",
            "rootUri": "file:///mnt/Data1T/Mnote_data/users/user_1/我的空间",
            "actorId": "user_1"
        });

        let env = acp_allowed_roots_env_for_payload(&payload).expect("env");
        let allowed_roots = serde_json::from_str::<Vec<String>>(
            env.get("MNOTE_AI_ALLOWED_ROOTS_JSON")
                .expect("allowed roots"),
        )
        .expect("allowed roots json");
        assert_eq!(
            allowed_roots,
            vec!["/mnt/Data1T/Mnote_data/users/user_1/我的空间"]
        );
        assert_eq!(
            env.get("MNOTE_AI_WORKSPACE_ROOT").map(String::as_str),
            Some("/mnt/Data1T/Mnote_data/users/user_1/我的空间")
        );
        assert!(!env
            .get("MNOTE_AI_ALLOWED_ROOTS_JSON")
            .unwrap()
            .contains("/mnt/Data1T/Mnote_data/users/user_2"));
        let scope = serde_json::from_str::<serde_json::Value>(
            env.get("MNOTE_AI_ACCESS_SCOPE_JSON").expect("scope"),
        )
        .expect("scope json");
        assert_eq!(scope["permissionLevel"], "read_write");
        assert_eq!(
            scope["allowedRoots"][0],
            "file:///mnt/Data1T/Mnote_data/users/user_1/我的空间"
        );
    }

    #[test]
    fn acp_runtime_env_limits_shared_scope_to_granted_files() {
        let _guard = env_lock().lock().expect("env lock");
        let config_root = std::env::temp_dir().join(format!(
            "mnote-shared-acp-env-config-{}",
            std::process::id()
        ));
        let _ = fs::remove_dir_all(&config_root);
        fs::create_dir_all(&config_root).expect("config root");
        let share_grants_file = config_root.join("share-grants.json");
        fs::write(
            &share_grants_file,
            json!({
                "grants": [{
                    "id": "share_grant_env_1",
                    "shareId": "share_env_1",
                    "ownerUserId": "owner_1",
                    "targetUserId": "target_1",
                    "rootUri": "file:///mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space",
                    "allowedResourceIds": ["local-md:README.md"],
                    "permission": "read",
                    "capabilities": ["ai", "share"],
                    "createdAt": "1",
                    "active": true
                }]
            })
            .to_string(),
        )
        .expect("share grants");
        std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file);
        let payload = json!({
            "workspaceId": "local-workspace-shared",
            "documentId": "local-md:README.md",
            "sessionId": "sess_shared_env_1",
            "sourceKind": "local_folder",
            "rootUri": "file:///mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space",
            "shareId": "share_env_1",
            "actorId": "target_1"
        });

        let env = acp_allowed_roots_env_for_payload(&payload).expect("env");
        let allowed_roots = serde_json::from_str::<Vec<String>>(
            env.get("MNOTE_AI_ALLOWED_ROOTS_JSON")
                .expect("allowed roots"),
        )
        .expect("allowed roots json");
        assert_eq!(
            allowed_roots,
            vec!["/mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space/README.md"]
        );
        assert_eq!(
            env.get("MNOTE_AI_WORKSPACE_ROOT").map(String::as_str),
            Some("/mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space")
        );
        let scope = serde_json::from_str::<serde_json::Value>(
            env.get("MNOTE_AI_ACCESS_SCOPE_JSON").expect("scope"),
        )
        .expect("scope json");
        assert_eq!(scope["permissionLevel"], "shared_read");
        assert_eq!(scope["shareContext"]["shareId"], "share_env_1");
        assert_eq!(scope["allowedResourceIds"][0], "local-md:README.md");
        assert_eq!(
            scope["allowedFilePaths"][0],
            "/mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space/README.md"
        );

        std::env::remove_var("MNOTE_SHARE_GRANTS_FILE");
        let _ = fs::remove_dir_all(&config_root);
    }

    #[test]
    fn hermes_client_run_actor_falls_back_to_dev_user_for_cookie_auth() {
        let state = test_state();
        let mut headers = HeaderMap::new();
        headers.insert("cookie", "mnote_web_convex_token=token_1".parse().unwrap());
        let context = RequestContext::from_http_parts(
            &axum::http::Method::POST,
            &"/api/hermes/client/runs".parse().expect("uri"),
            &headers,
        );
        let (actor_id, actor_type) = effective_run_actor(&state, &context);
        assert_eq!(actor_id, "dev-user");
        assert_eq!(actor_type, "devFallback");
        let mut payload = json!({"message": "读取当前页面"});
        stamp_run_actor(&mut payload, &actor_id, &actor_type);
        let body = build_run_upstream_body(&context, payload).expect("body");
        let instructions = body["instructions"].as_str().expect("instructions");
        assert!(instructions.contains("\"actorId\":\"dev-user\""));
        assert!(instructions.contains("\"actorType\":\"devFallback\""));
        assert!(!instructions.contains("\"actorId\":\"anonymous\""));
    }

    #[test]
    fn hermes_client_run_body_carries_agent_profile() {
        let context = RequestContext::from_http_parts(
            &axum::http::Method::POST,
            &"/api/hermes/client/runs".parse().expect("uri"),
            &HeaderMap::new(),
        );
        let body = build_run_upstream_body(
            &context,
            json!({
                "workspaceId": "ws_1",
                "documentId": "doc_1",
                "sessionId": "sess_1",
                "message": "用当前 agent 回答",
                "profile": "chemist",
                "traceId": "trace_1"
            }),
        )
        .expect("body");
        assert_eq!(body["profile"], "chemist");
        let instructions = body["instructions"].as_str().expect("instructions");
        assert!(instructions.contains("\"profile\":\"chemist\""));
    }

    #[test]
    fn hermes_client_run_guidance_prefers_markdown_edit_for_plain_body_edits() {
        let context = RequestContext::from_http_parts(
            &axum::http::Method::POST,
            &"/api/hermes/client/runs".parse().expect("uri"),
            &HeaderMap::new(),
        );
        let body = build_run_upstream_body(
            &context,
            json!({
                "workspaceId": "ws_1",
                "documentId": "doc_1",
                "sessionId": "sess_1",
                "message": "把第一段改成测试123",
                "pageContext": {
                    "aiContext": {
                        "schema": "mnote.page_ai_context.v1",
                        "pageText": "第一段\n第二段",
                        "contextBlocks": [{"blockId": "block_1", "text": "第一段"}]
                    }
                },
                "traceId": "trace_1"
            }),
        )
        .expect("body");
        let instructions = body["instructions"].as_str().expect("instructions");
        assert!(instructions.contains("普通正文 search/replace、局部段落替换或全文 markdown 替换，应优先调用 mnote_doc_markdown_edit"));
        assert!(instructions.contains("\"mnote_doc_markdown_edit for plain body search/replace or full markdown replacement\""));
        assert!(!instructions.contains(
            "简单小段落编辑或同一页内多个普通叶子块操作，应优先用 mnote_doc_apply_block_ops"
        ));
    }

    #[tokio::test]
    async fn hermes_client_profile_skill_and_memory_routes_use_local_bff_without_upstream() {
        let _guard = env_lock().lock().expect("env lock");
        std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL");
        std::env::remove_var("MNOTE_HERMES_UPSTREAM_URL");
        let hermes_home = std::env::temp_dir().join(format!(
            "mnote-web-hermes-client-test-{}",
            std::process::id()
        ));
        let _ = fs::remove_dir_all(&hermes_home);
        for name in ["draft", "installed-demo", "builtin-demo"] {
            fs::create_dir_all(hermes_home.join("skills").join("writing").join(name))
                .expect("test skill dir");
            fs::write(
                hermes_home
                    .join("skills")
                    .join("writing")
                    .join(name)
                    .join("SKILL.md"),
                format!("# {name}\n\n写作助手"),
            )
            .expect("test skill");
        }
        fs::create_dir_all(hermes_home.join("skills").join("copied-demo"))
            .expect("test copied skill dir");
        fs::write(
            hermes_home
                .join("skills")
                .join("copied-demo")
                .join("SKILL.md"),
            "# copied-demo\n\n复制技能",
        )
        .expect("test copied skill");
        fs::write(
            hermes_home.join("skills").join(".bundled_manifest"),
            "builtin-demo:abc123\n",
        )
        .expect("test bundled manifest");
        fs::create_dir_all(hermes_home.join("skills").join(".hub")).expect("test hub dir");
        fs::write(
            hermes_home.join("skills").join(".hub").join("lock.json"),
            json!({
                "installed": {
                    "installed-demo": {
                        "source": "skills.sh",
                        "install_path": "writing/installed-demo"
                    }
                }
            })
            .to_string(),
        )
        .expect("test hub lock");
        fs::write(
            hermes_home.join("skills").join(".usage.json"),
            json!({
                "draft": {
                    "created_by": "agent",
                    "created_at": "2026-05-14T00:00:00Z",
                    "patch_count": 2
                }
            })
            .to_string(),
        )
        .expect("test usage");
        std::env::set_var("HERMES_HOME", &hermes_home);
        std::env::set_var("MNOTE_WEB_HERMES_BIN", "__missing_hermes_for_test__");
        let cases = [
            ("GET", "/api/hermes/client/profiles", None),
            ("GET", "/api/hermes/client/profiles/chemist", None),
            (
                "GET",
                "/api/hermes/client/profile-memory?profile=chemist",
                None,
            ),
            ("GET", "/api/hermes/client/skills?profile=chemist", None),
            (
                "PUT",
                "/api/hermes/client/profiles/active",
                Some(json!({"name": "chemist"})),
            ),
            (
                "POST",
                "/api/hermes/client/profile-memory",
                Some(json!({"profile": "chemist", "section": "soul", "content": "test"})),
            ),
            (
                "PUT",
                "/api/hermes/client/skills/toggle",
                Some(json!({"name": "chem", "enabled": true})),
            ),
        ];
        for (method, uri, body) in cases {
            let request = Request::builder()
                .method(method)
                .uri(uri)
                .header("content-type", "application/json")
                .header("x-mnote-actor-id", "user_1")
                .body(Body::from(body.unwrap_or_else(|| json!({})).to_string()))
                .expect("request");
            let response = app().oneshot(request).await.expect("response");
            assert_eq!(response.status(), StatusCode::OK, "{uri}");
        }
        assert_eq!(
            fs::read_to_string(hermes_home.join("active_profile"))
                .expect("active profile")
                .trim(),
            "chemist"
        );
        assert_eq!(
            fs::read_to_string(hermes_home.join("profiles").join("chemist").join("SOUL.md"))
                .expect("soul"),
            "test"
        );
        let request = Request::builder()
            .method("GET")
            .uri("/api/hermes/client/skills?profile=chemist")
            .header("x-mnote-actor-id", "user_1")
            .body(Body::empty())
            .expect("request");
        let response = app().oneshot(request).await.expect("response");
        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("skills json");
        let skills = payload["categories"]
            .as_array()
            .expect("categories")
            .iter()
            .flat_map(|category| category["skills"].as_array().into_iter().flatten())
            .map(|skill| {
                (
                    skill["name"].as_str().unwrap_or_default().to_string(),
                    skill.clone(),
                )
            })
            .collect::<HashMap<_, _>>();
        assert_eq!(skills["draft"]["origin"], "generated");
        assert_eq!(skills["draft"]["createdBy"], "agent");
        assert_eq!(skills["installed-demo"]["source"], "hub");
        assert_eq!(skills["installed-demo"]["origin"], "installed");
        assert_eq!(skills["builtin-demo"]["source"], "builtin");
        assert_eq!(skills["copied-demo"]["origin"], "copied");
        std::env::remove_var("HERMES_HOME");
        std::env::remove_var("MNOTE_WEB_HERMES_BIN");
        let _ = fs::remove_dir_all(&hermes_home);
    }

    #[tokio::test]
    async fn hermes_client_tools_uses_manifest_and_profile_disabled_state() {
        let _guard = env_lock().lock().expect("env lock");
        let hermes_home = std::env::temp_dir().join(format!(
            "mnote-web-hermes-tools-local-{}",
            std::process::id()
        ));
        let _ = fs::remove_dir_all(&hermes_home);
        let profile_dir = hermes_home.join("profiles").join("chemist");
        fs::create_dir_all(&profile_dir).expect("profile dir");
        fs::write(
            profile_dir.join("config.yaml"),
            "mnote:\n  tools:\n    disabled:\n      - mnote.block.replace\n",
        )
        .expect("profile config");
        std::env::set_var("HERMES_HOME", &hermes_home);

        let response = app()
            .oneshot(
                Request::builder()
                    .method("GET")
                    .uri("/api/hermes/client/tools?scope=mnote&profile=chemist")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("tools json");
        let tools = payload["tools"]
            .as_array()
            .expect("tools")
            .iter()
            .map(|tool| {
                (
                    tool["name"].as_str().unwrap_or_default().to_string(),
                    tool.clone(),
                )
            })
            .collect::<HashMap<_, _>>();
        assert!(tools.contains_key("mnote.doc.fetch"));
        assert!(tools.contains_key("mnote.block.fetch"));
        assert!(tools.contains_key("mnote.block.replace"));
        assert!(tools.contains_key("mnote.block.insert_after"));
        assert!(tools.contains_key("mnote.block.delete"));
        assert!(tools.contains_key("mnote.block.move_after"));
        assert!(tools.contains_key("mnote.doc.apply_block_ops"));
        assert_eq!(tools["mnote.doc.fetch"]["enabled"], true);
        assert_eq!(tools["mnote.block.replace"]["enabled"], false);
        assert_eq!(tools["mnote.block.replace"]["status"], "disabled");
        assert_eq!(
            tools["mnote.block.replace"]["unavailableReason"],
            "当前 Hermes profile 已关闭该 mnote tool"
        );

        let toggle_response = app()
            .oneshot(
                Request::builder()
                    .method("PUT")
                    .uri("/api/hermes/client/tools/toggle")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "profile": "chemist",
                            "name": "mnote.block.replace",
                            "enabled": true
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(toggle_response.status(), StatusCode::OK);

        let response = app()
            .oneshot(
                Request::builder()
                    .method("GET")
                    .uri("/api/hermes/client/tools?scope=mnote&profile=chemist")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("tools json");
        let tools = payload["tools"]
            .as_array()
            .expect("tools")
            .iter()
            .map(|tool| {
                (
                    tool["name"].as_str().unwrap_or_default().to_string(),
                    tool.clone(),
                )
            })
            .collect::<HashMap<_, _>>();
        assert_eq!(tools["mnote.block.replace"]["enabled"], true);
        assert_eq!(tools["mnote.block.replace"]["status"], "available");

        std::env::remove_var("HERMES_HOME");
        let _ = fs::remove_dir_all(&hermes_home);
    }
}
