use super::hermes_client;
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{artifact, block, doc, manifest, page, resource, ToolCallInput};
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::env;
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use tracing::{info, warn};

const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_HERMES_TOOL_OWNER: &str = "x-mnote-hermes-tool-owner";

fn record_local_agent_write_rejection(
    context: &RequestContext,
    input: &ToolCallInput,
    profile: &str,
    code: &str,
    message: &str,
) {
    let Some(run_id) = input
        .run_id
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
    else {
        return;
    };
    let payload = json!({
        "toolName": input.tool_name.clone(),
        "toolCallId": input.effective_tool_call_id(),
        "sessionId": input.session_id.clone(),
        "workspaceId": input.effective_workspace_id(),
        "documentId": input.effective_document_id(),
        "rootUri": input.effective_root_uri(),
        "actorId": input.actor_id.clone(),
        "actorType": input.arg_string("actorType").or_else(|| input.arg_string("actor_type")),
        "permissionLevel": "read_only",
        "rejection": {
            "code": code,
            "message": message,
            "source": "local_ai_scope",
            "toolName": input.tool_name.clone(),
        },
    });
    if let Err(error) =
        hermes_client::local_agent_audit_record_write_rejected(context, payload, run_id, profile)
    {
        warn!(
            error = ?error,
            run_id = %run_id,
            tool_name = %input.tool_name,
            "本地 agent 拒绝审计写入失败"
        );
    }
}

fn record_local_agent_tool_write(
    context: &RequestContext,
    input: &ToolCallInput,
    profile: &str,
    result: &Value,
) {
    if input.effective_source_kind().as_deref() != Some("local_folder") {
        return;
    }
    let Some(run_id) = input
        .run_id
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
    else {
        return;
    };
    let document_id = input.effective_document_id().unwrap_or_default();
    let command_name = result
        .get("commandName")
        .or_else(|| result.pointer("/applyResult/commandName"))
        .and_then(Value::as_str)
        .unwrap_or(input.tool_name.as_str());
    let file_version = result
        .get("fileVersion")
        .or_else(|| result.pointer("/result/fileVersion"))
        .or_else(|| result.pointer("/applyResult/result/fileVersion"))
        .cloned()
        .unwrap_or(Value::Null);
    let changed_files = json!([{
        "path": document_id,
        "changeType": "modified",
        "summary": format!("MNote tool 写入：{command_name}"),
        "fileVersion": file_version
    }]);
    let payload = json!({
        "toolName": input.tool_name.clone(),
        "toolCallId": input.effective_tool_call_id(),
        "sessionId": input.session_id.clone(),
        "workspaceId": input.effective_workspace_id(),
        "documentId": input.effective_document_id(),
        "rootUri": input.effective_root_uri(),
        "actorId": input.actor_id.clone(),
        "actorType": input.arg_string("actorType").or_else(|| input.arg_string("actor_type")),
        "permissionLevel": "read_write",
        "commandName": command_name,
        "changedFiles": changed_files,
    });
    if let Err(error) =
        hermes_client::local_agent_audit_record_tool_write(context, payload, run_id, profile)
    {
        warn!(
            error = ?error,
            run_id = %run_id,
            tool_name = %input.tool_name,
            "本地 agent tool 写入审计失败"
        );
    }
}

pub async fn mnote_audit(
    Extension(context): Extension<RequestContext>,
    Query(query): Query<HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let trace_id = query.get("traceId").map(String::as_str);
    let tool_call_id = query.get("toolCallId").map(String::as_str);
    let persisted_only = query
        .get("persistedOnly")
        .map(|value| value == "true" || value == "1")
        .unwrap_or(false);
    let events = if persisted_only {
        audit_persisted_events(trace_id, tool_call_id)
    } else {
        audit_events(trace_id, tool_call_id)
    };
    Ok((
        StatusCode::OK,
        stamp_tool_headers(),
        Json(json!({
            "ok": true,
            "traceId": context.trace.trace_id,
            "auditStore": if persisted_only { "jsonl" } else { "memory" },
            "events": events
        })),
    ))
}

pub async fn mnote_manifest(
    Extension(context): Extension<RequestContext>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    Ok((
        StatusCode::OK,
        stamp_tool_headers(),
        Json(json!({
            "ok": true,
            "traceId": context.trace.trace_id,
            "manifest": manifest::manifest()
        })),
    ))
}

pub async fn mnote_call(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Json(input): Json<ToolCallInput>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    let response_body = execute_mnote_tool_call(&state, &context, input).await?;
    Ok((StatusCode::OK, stamp_tool_headers(), Json(response_body)))
}

pub(crate) async fn execute_mnote_tool_call(
    state: &AppState,
    context: &RequestContext,
    input: ToolCallInput,
) -> Result<Value, WebError> {
    let context = authenticated_tool_context(context, &input)?;
    let trace_id = input
        .effective_trace_id(&context.trace.trace_id)
        .to_string();
    let tool_call_id = input.effective_tool_call_id();
    let workspace_id = input.effective_workspace_id();
    let document_id = input.effective_document_id();
    let dry_run = input.dry_run.unwrap_or(false);
    let effect = if dry_run {
        "dry_run"
    } else if is_read_tool(&input.tool_name) {
        "read"
    } else {
        "write"
    };
    let idempotency_key = idempotency_cache_key(
        &input,
        workspace_id.as_deref(),
        document_id.as_deref(),
        dry_run,
    );
    info!(
        trace_id = %trace_id,
        session_id = input.session_id.as_deref().unwrap_or(""),
        run_id = input.run_id.as_deref().unwrap_or(""),
        tool_call_id = %tool_call_id,
        tool_name = %input.tool_name,
        workspace_id = workspace_id.as_deref().unwrap_or(""),
        document_id = document_id.as_deref().unwrap_or(""),
        actor_id = input.actor_id.as_deref().unwrap_or(""),
        dry_run,
        "mnote Hermes tool call started"
    );
    let profile = input
        .profile
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
        .or_else(|| input.arg_string("profile"))
        .or_else(hermes_client::active_profile_name)
        .unwrap_or_else(|| "default".into());
    audit_push(json!({
        "phase": "started",
        "traceId": trace_id,
        "sessionId": input.session_id,
        "runId": input.run_id,
        "toolCallId": tool_call_id,
        "toolName": input.tool_name,
        "workspaceId": workspace_id,
        "documentId": document_id,
            "actorId": input.actor_id,
            "dryRun": dry_run
    }));
    if hermes_client::is_mnote_tool_disabled(&profile, &input.tool_name) {
        let error = WebError::new(
            StatusCode::FORBIDDEN,
            "mnote_tool_disabled",
            "当前 Hermes profile 已关闭该 mnote tool",
        )
        .with_context(&context)
        .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools");
        audit_push(json!({
            "phase": "failed",
            "traceId": trace_id,
            "sessionId": input.session_id,
            "runId": input.run_id,
            "toolCallId": tool_call_id,
            "toolName": input.tool_name,
            "workspaceId": workspace_id,
            "documentId": document_id,
            "actorId": input.actor_id,
            "profile": profile,
            "status": error.status().as_u16(),
            "message": error.message()
        }));
        return Err(error);
    }
    if let Err(error) = ensure_workspace_context(&context, workspace_id.as_deref()) {
        audit_push(json!({
            "phase": "failed",
            "traceId": trace_id,
            "sessionId": input.session_id,
            "runId": input.run_id,
            "toolCallId": tool_call_id,
            "toolName": input.tool_name,
            "workspaceId": workspace_id,
            "documentId": document_id,
            "actorId": input.actor_id,
            "status": error.status().as_u16(),
            "message": error.message()
        }));
        return Err(error);
    }
    if !dry_run && !is_read_tool(&input.tool_name) && is_shared_read_scope(&input) {
        let error = WebError::new(
            StatusCode::FORBIDDEN,
            "mnote_tool_shared_read_write_forbidden",
            "共享只读 AI 上下文不能执行写工具",
        )
        .with_context(&context)
        .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools");
        audit_push(json!({
            "phase": "failed",
            "traceId": trace_id,
            "sessionId": input.session_id,
            "runId": input.run_id,
            "toolCallId": tool_call_id,
            "toolName": input.tool_name,
            "workspaceId": workspace_id,
            "documentId": document_id,
            "actorId": input.actor_id,
            "status": error.status().as_u16(),
            "message": error.message(),
            "permissionLevel": "shared_read"
        }));
        record_local_agent_write_rejection(
            &context,
            &input,
            &profile,
            error.code(),
            error.message(),
        );
        return Err(error);
    }
    if let Err(error) = ensure_tool_capability_scope(&context, &input) {
        audit_push(json!({
            "phase": "failed",
            "traceId": trace_id,
            "sessionId": input.session_id,
            "runId": input.run_id,
            "toolCallId": tool_call_id,
            "toolName": input.tool_name,
            "workspaceId": workspace_id,
            "documentId": document_id,
            "actorId": input.actor_id,
            "status": error.status().as_u16(),
            "message": error.message(),
            "capabilityScope": input.capability_scope
        }));
        return Err(error);
    }
    if let Some(cached) = idempotency_key.as_deref().and_then(idempotency_cache_get) {
        info!(
            trace_id = %trace_id,
            session_id = input.session_id.as_deref().unwrap_or(""),
            run_id = input.run_id.as_deref().unwrap_or(""),
            tool_call_id = %tool_call_id,
            tool_name = %input.tool_name,
            workspace_id = workspace_id.as_deref().unwrap_or(""),
            document_id = document_id.as_deref().unwrap_or(""),
            actor_id = input.actor_id.as_deref().unwrap_or(""),
            "mnote Hermes tool call idempotency replay"
        );
        audit_push(json!({
            "phase": "idempotency_replay",
            "traceId": trace_id,
            "sessionId": cached.get("sessionId").cloned().unwrap_or(Value::Null),
            "runId": cached.get("runId").cloned().unwrap_or(Value::Null),
            "toolCallId": cached.get("toolCallId").cloned().unwrap_or(Value::Null),
            "toolName": cached.get("toolName").cloned().unwrap_or(Value::Null),
            "audit": cached.get("audit").cloned().unwrap_or(Value::Null)
        }));
        return Ok(cached);
    }
    let result = match input.tool_name.as_str() {
        "mnote.doc.fetch" => doc::doc_fetch(&state, &context, &input).await,
        "mnote.doc.find" => doc::doc_find(&state, &context, &input).await,
        "mnote.doc.plan_update" => doc::plan_update(&state, &context, &input).await,
        "mnote.block.fetch" => block::block_fetch(&state, &context, &input).await,
        "mnote.block.replace" => block::block_replace(&state, &context, &input).await,
        "mnote.block.insert_after" => block::block_insert_after(&state, &context, &input).await,
        "mnote.block.delete" => block::block_delete(&state, &context, &input).await,
        "mnote.block.move_after" => block::block_move_after(&state, &context, &input).await,
        "mnote.doc.apply_block_ops" => block::doc_apply_block_ops(&state, &context, &input).await,
        "mnote.doc.markdown_edit" => doc::doc_markdown_edit(&state, &context, &input).await,
        "mnote.page.get" => page::page_get(&state, &context, &input).await,
        "mnote.page.save" => page::page_save(&state, &context, &input).await,
        "mnote.page.update_title" => page::update_title(&state, &context, &input).await,
        "mnote.page.update_options" => page::update_options(&state, &context, &input).await,
        "mnote.mindmap.fetch" => resource::mindmap_fetch(&context, &input).await,
        "mnote.mindmap.apply_ops" => resource::mindmap_apply_ops(&context, &input).await,
        "mnote.office.fetch_summary" => resource::office_fetch_summary(&context, &input).await,
        "mnote.office.propose_changes" => resource::office_propose_changes(&context, &input).await,
        "mnote.artifact.create_summary" => artifact::create_summary(&state, &context, &input).await,
        "mnote.artifact.create_ai_note" => artifact::create_ai_note(&state, &context, &input).await,
        _ => Err(
            WebError::bad_request_code("mnote_tool_unknown", "未知 mnote Hermes tool")
                .with_context(&context)
                .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
                .with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"),
        ),
    };
    if let Err(error) = &result {
        warn!(
            trace_id = %trace_id,
            session_id = input.session_id.as_deref().unwrap_or(""),
            run_id = input.run_id.as_deref().unwrap_or(""),
            tool_call_id = %tool_call_id,
            tool_name = %input.tool_name,
            workspace_id = workspace_id.as_deref().unwrap_or(""),
            document_id = document_id.as_deref().unwrap_or(""),
            actor_id = input.actor_id.as_deref().unwrap_or(""),
            status = %error.status(),
            message = %error.message(),
            "mnote Hermes tool call failed"
        );
        if matches!(
            error.code(),
            "mnote_tool_ai_scope_write_forbidden" | "mnote_tool_shared_read_write_forbidden"
        ) {
            record_local_agent_write_rejection(
                &context,
                &input,
                &profile,
                error.code(),
                error.message(),
            );
        }
        audit_push(json!({
            "phase": "failed",
            "traceId": trace_id,
            "sessionId": input.session_id,
            "runId": input.run_id,
            "toolCallId": tool_call_id,
            "toolName": input.tool_name,
            "workspaceId": workspace_id,
            "documentId": document_id,
            "actorId": input.actor_id,
            "status": error.status().as_u16(),
            "message": error.message()
        }));
    }
    let result = result?;
    if !dry_run && !is_read_tool(&input.tool_name) {
        record_local_agent_tool_write(&context, &input, &profile, &result);
    }
    let command_id = result.get("commandId").cloned().unwrap_or(Value::Null);
    info!(
        trace_id = %trace_id,
        session_id = input.session_id.as_deref().unwrap_or(""),
        run_id = input.run_id.as_deref().unwrap_or(""),
        tool_call_id = %tool_call_id,
        tool_name = %input.tool_name,
        workspace_id = workspace_id.as_deref().unwrap_or(""),
        document_id = document_id.as_deref().unwrap_or(""),
        actor_id = input.actor_id.as_deref().unwrap_or(""),
        effect,
        "mnote Hermes tool call completed"
    );
    let response_body = json!({
            "ok": true,
            "toolName": input.tool_name,
            "toolCallId": tool_call_id,
            "traceId": trace_id,
            "sessionId": input.session_id,
            "runId": input.run_id,
            "result": result,
            "audit": {
                "effect": effect,
                "commandId": command_id,
                "workspaceId": workspace_id,
                "documentId": document_id,
                "actorId": input.actor_id,
                "dryRun": dry_run,
                "idempotencyKey": input.idempotency_key,
                "capabilityScope": input.capability_scope
            },
            "error": null
    });
    if let Some(key) = idempotency_key {
        idempotency_cache_put(key, response_body.clone());
    }
    audit_push(json!({
        "phase": "completed",
        "traceId": response_body.get("traceId").cloned().unwrap_or(Value::Null),
        "sessionId": response_body.get("sessionId").cloned().unwrap_or(Value::Null),
        "runId": response_body.get("runId").cloned().unwrap_or(Value::Null),
        "toolCallId": response_body.get("toolCallId").cloned().unwrap_or(Value::Null),
        "toolName": response_body.get("toolName").cloned().unwrap_or(Value::Null),
        "audit": response_body.get("audit").cloned().unwrap_or(Value::Null)
    }));
    Ok(response_body)
}

fn ensure_tool_capability_scope(
    context: &RequestContext,
    input: &ToolCallInput,
) -> Result<(), WebError> {
    let required = required_capability_scope(&input.tool_name);
    if required.is_empty()
        || declared_capability_scope_covers(input.capability_scope.as_ref(), &required)
    {
        return Ok(());
    }
    Err(WebError::new(
        StatusCode::FORBIDDEN,
        "mnote_tool_capability_scope_forbidden",
        "调用方声明的 capabilityScope 未覆盖目标 mnote tool 所需能力",
    )
    .with_context(context)
    .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
    .with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"))
}

fn required_capability_scope(tool_name: &str) -> Vec<String> {
    manifest::manifest()
        .get("tools")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .find(|tool| tool.get("name").and_then(Value::as_str) == Some(tool_name))
        .and_then(|tool| tool.get("capabilityScope").and_then(Value::as_array))
        .into_iter()
        .flatten()
        .filter_map(Value::as_str)
        .map(normalize_capability_scope)
        .filter(|value| !value.is_empty())
        .collect()
}

fn declared_capability_scope_covers(declared: Option<&Vec<String>>, required: &[String]) -> bool {
    let Some(declared) = declared else {
        // 兼容旧调用方：缺省 capabilityScope 不改变既有执行路径。
        return true;
    };
    let declared = declared
        .iter()
        .map(|value| normalize_capability_scope(value))
        .filter(|value| !value.is_empty())
        .collect::<Vec<_>>();
    required.iter().all(|scope| {
        declared
            .iter()
            .any(|candidate| capability_scope_satisfies(candidate, scope))
    })
}

fn capability_scope_satisfies(candidate: &str, required: &str) -> bool {
    if candidate == required {
        return true;
    }
    required
        .strip_suffix(".read")
        .map(|prefix| format!("{prefix}.write"))
        .as_deref()
        == Some(candidate)
}

fn normalize_capability_scope(value: &str) -> String {
    value.trim().to_ascii_lowercase()
}

fn is_read_tool(tool_name: &str) -> bool {
    matches!(
        tool_name,
        "mnote.page.get"
            | "mnote.doc.fetch"
            | "mnote.doc.find"
            | "mnote.block.fetch"
            | "mnote.mindmap.fetch"
            | "mnote.office.fetch_summary"
            | "mnote.office.propose_changes"
    )
}

fn is_shared_read_scope(input: &ToolCallInput) -> bool {
    let direct = input
        .arg_string("permissionLevel")
        .or_else(|| input.arg_string("permission_level"));
    if matches!(direct.as_deref(), Some("shared_read")) {
        return true;
    }
    input
        .arg_value("aiAccessScope")
        .and_then(|value| {
            value
                .get("permissionLevel")
                .or_else(|| value.get("permission_level"))
                .and_then(Value::as_str)
                .map(str::trim)
                .map(ToOwned::to_owned)
        })
        .as_deref()
        == Some("shared_read")
}

fn audit_log() -> &'static Mutex<Vec<Value>> {
    static LOG: OnceLock<Mutex<Vec<Value>>> = OnceLock::new();
    LOG.get_or_init(|| Mutex::new(Vec::new()))
}

fn audit_push(event: Value) {
    if let Ok(mut log) = audit_log().lock() {
        log.push(event.clone());
        let overflow = log.len().saturating_sub(500);
        if overflow > 0 {
            log.drain(0..overflow);
        }
    }
    if let Err(error) = audit_append_persistent(&event) {
        warn!(message = %error, "mnote Hermes tool audit 持久化失败");
    }
}

fn audit_events(trace_id: Option<&str>, tool_call_id: Option<&str>) -> Vec<Value> {
    let Ok(log) = audit_log().lock() else {
        return Vec::new();
    };
    log.iter()
        .filter(|event| {
            trace_id
                .map(|expected| event.get("traceId").and_then(Value::as_str) == Some(expected))
                .unwrap_or(true)
        })
        .filter(|event| {
            tool_call_id
                .map(|expected| event.get("toolCallId").and_then(Value::as_str) == Some(expected))
                .unwrap_or(true)
        })
        .cloned()
        .collect()
}

fn audit_log_path() -> PathBuf {
    env::var("MNOTE_HERMES_TOOL_AUDIT_LOG")
        .ok()
        .filter(|value| !value.trim().is_empty())
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("tmp").join("mnote-hermes-tool-audit.jsonl"))
}

fn audit_append_persistent(event: &Value) -> Result<(), String> {
    let path = audit_log_path();
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).map_err(|error| error.to_string())?;
    }
    let mut file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(&path)
        .map_err(|error| error.to_string())?;
    let line = serde_json::to_string(event).map_err(|error| error.to_string())?;
    writeln!(file, "{line}").map_err(|error| error.to_string())
}

fn audit_persisted_events(trace_id: Option<&str>, tool_call_id: Option<&str>) -> Vec<Value> {
    let path = audit_log_path();
    let Ok(file) = File::open(path) else {
        return Vec::new();
    };
    BufReader::new(file)
        .lines()
        .map_while(Result::ok)
        .filter_map(|line| serde_json::from_str::<Value>(&line).ok())
        .filter(|event| {
            trace_id
                .map(|expected| event.get("traceId").and_then(Value::as_str) == Some(expected))
                .unwrap_or(true)
        })
        .filter(|event| {
            tool_call_id
                .map(|expected| event.get("toolCallId").and_then(Value::as_str) == Some(expected))
                .unwrap_or(true)
        })
        .collect()
}

fn idempotency_cache() -> &'static Mutex<HashMap<String, Value>> {
    static CACHE: OnceLock<Mutex<HashMap<String, Value>>> = OnceLock::new();
    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}

fn idempotency_cache_key(
    input: &ToolCallInput,
    workspace_id: Option<&str>,
    document_id: Option<&str>,
    dry_run: bool,
) -> Option<String> {
    if dry_run || is_read_tool(&input.tool_name) {
        return None;
    }
    let idempotency_key = input.idempotency_key.as_deref()?.trim();
    if idempotency_key.is_empty() {
        return None;
    }
    Some(format!(
        "{}|{}|{}|{}",
        input.tool_name,
        workspace_id.unwrap_or(""),
        document_id.unwrap_or(""),
        idempotency_key
    ))
}

fn idempotency_cache_get(key: &str) -> Option<Value> {
    idempotency_cache().lock().ok()?.get(key).cloned()
}

fn idempotency_cache_put(key: String, response: Value) {
    if let Ok(mut cache) = idempotency_cache().lock() {
        cache.insert(key, response);
    }
}

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

fn authenticated_tool_context(
    context: &RequestContext,
    input: &ToolCallInput,
) -> Result<RequestContext, WebError> {
    let context_actor = context.auth.actor_id.trim();
    if !context_actor.is_empty() && context_actor != "anonymous" && context_actor != "hermes" {
        return Ok(context.clone());
    }
    let has_cookie_or_auth =
        context.auth.authorization.is_some() || context.auth.cookie_header.is_some();
    let actor_id = input
        .actor_id
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty() && *value != "anonymous" && *value != "hermes")
        .ok_or_else(|| {
            WebError::new(
                StatusCode::UNAUTHORIZED,
                "mnote_tool_unauthorized",
                "mnote Hermes tool 需要有效 actorId，不能使用 hermes/anonymous",
            )
            .with_context(context)
            .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
            .with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools")
        })?;
    if has_cookie_or_auth {
        let mut next = context.clone();
        next.auth.actor_id = actor_id.to_string();
        next.auth.actor_type = input
            .arg_string("actorType")
            .or_else(|| input.arg_string("actor_type"))
            .unwrap_or_else(|| "user".into());
        if next.auth.session_id.is_none() {
            next.auth.session_id = input.session_id.clone();
        }
        return Ok(next);
    }
    let has_run_identity = input
        .session_id
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .is_some()
        && input
            .run_id
            .as_deref()
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .is_some()
        && input
            .tool_call_id
            .as_deref()
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .is_some()
        && input
            .trace_id
            .as_deref()
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .is_some();
    if !has_run_identity {
        return Err(WebError::new(
            StatusCode::UNAUTHORIZED,
            "mnote_tool_unauthorized",
            "mnote Hermes tool 委托调用缺少 sessionId/runId/toolCallId/traceId",
        )
        .with_context(context)
        .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"));
    }
    let mut next = context.clone();
    next.auth.actor_id = actor_id.to_string();
    next.auth.actor_type = "user".into();
    if next.auth.session_id.is_none() {
        next.auth.session_id = input.session_id.clone();
    }
    Ok(next)
}

fn ensure_workspace_context(
    context: &RequestContext,
    input_workspace_id: Option<&str>,
) -> Result<(), WebError> {
    let Some(input_workspace_id) = input_workspace_id
        .map(str::trim)
        .filter(|value| !value.is_empty())
    else {
        return Ok(());
    };
    let Some(header_workspace_id) = context
        .workspace
        .workspace_id
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
    else {
        return Ok(());
    };
    if input_workspace_id == header_workspace_id {
        return Ok(());
    }
    Err(WebError::new(
        StatusCode::FORBIDDEN,
        "workspace_context_conflict",
        "mnote Hermes tool 请求的 workspaceId 与请求上下文不一致",
    )
    .with_context(context)
    .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
    .with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"))
}

fn stamp_tool_headers() -> HeaderMap {
    let mut headers = HeaderMap::new();
    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_TOOL_OWNER.as_bytes()) {
        headers.insert(name, HeaderValue::from_static("mnote-web-hermes-tools"));
    }
    headers
}

#[cfg(test)]
mod tests {
    use crate::app::{build_app, AppConfig, AppState};
    use axum::body::{to_bytes, Body};
    use axum::http::{Request, StatusCode};
    use serde_json::{json, Value};
    use std::collections::BTreeMap;
    use std::fs;
    use std::sync::Mutex;
    use tower::util::ServiceExt;

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

    fn app() -> axum::Router {
        build_app(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: Some(
                r#"{
                  "documents:getMeta": {
                    "id": "doc_1",
                    "workspace_id": "ws_demo",
                    "title": "服务端页面",
                    "can_edit": true,
                    "wide_layout": false,
                    "use_small_text": false,
                    "show_toc": true,
                    "block_count": 1
                  },
                  "documents:getContent": {
                    "title": "服务端页面",
                    "content": [
                      {
                        "id": "heading_1",
                        "type": "heading",
                        "props": { "level": 1 },
                        "content": [{ "type": "text", "text": "章节一" }]
                      },
                      {
                        "id": "p_1",
                        "type": "paragraph",
                        "content": [{ "type": "text", "text": "第一段" }]
                      },
                      {
                        "id": "p_2",
                        "type": "paragraph",
                        "content": [{ "type": "text", "text": "第二段" }]
                      }
                    ],
                    "revision": 7,
                    "conflict_detection_key": "doc_1:7"
                  }
                }"#
                .into(),
            ),
            mutation_fixtures_json: Some(
                r#"{
                  "documents:updateContent": {"ok": true, "revision": 8, "conflict_detection_key": "doc_1:8"},
                  "bridgeLogs:recordCommandLog": {"ok": true, "id": "cmd_log_fixture"},
                  "bridgeLogs:recordDomainEvent": {"ok": true, "id": "event_fixture"}
                }"#
                .into(),
            ),
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        }))
    }

    fn app_with_content(content: Value) -> axum::Router {
        build_app(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: Some(
                json!({
                    "documents:getMeta": {
                        "id": "doc_1",
                        "workspace_id": "ws_demo",
                        "title": "复杂块阻断页面",
                        "can_edit": true,
                        "wide_layout": false,
                        "use_small_text": false,
                        "show_toc": true,
                        "block_count": 6
                    },
                    "documents:getContent": {
                        "title": "复杂块阻断页面",
                        "content": content,
                        "revision": 7,
                        "conflict_detection_key": "doc_1:7"
                    }
                })
                .to_string(),
            ),
            mutation_fixtures_json: Some(
                r#"{
                  "documents:updateContent": {"ok": true, "revision": 8, "conflict_detection_key": "doc_1:8"},
                  "bridgeLogs:recordCommandLog": {"ok": true, "id": "cmd_log_fixture"},
                  "bridgeLogs:recordDomainEvent": {"ok": true, "id": "event_fixture"}
                }"#
                .into(),
            ),
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        }))
    }

    async fn call_tool_ok(payload: Value) -> Value {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(payload.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");
        serde_json::from_slice(&body).expect("json")
    }

    async fn call_tool_ok_with_app(app: axum::Router, payload: Value) -> Value {
        let response = app
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(payload.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");
        serde_json::from_slice(&body).expect("json")
    }

    async fn block_revision_ref(block_id: &str) -> String {
        let payload = call_tool_ok(json!({
            "toolName": "mnote.doc.fetch",
            "workspaceId": "ws_demo",
            "documentId": "doc_1",
            "sessionId": "sess_ref",
            "runId": "run_ref",
            "toolCallId": format!("call_ref_{block_id}"),
            "traceId": format!("trace_ref_{block_id}"),
            "capabilityScope": ["page.read"],
            "args": {"scope": "full", "detail": "with_ids"}
        }))
        .await;
        payload["result"]["blocks"]
            .as_array()
            .expect("blocks")
            .iter()
            .find(|block| block["blockId"] == json!(block_id))
            .and_then(|block| block["revisionRef"].as_str())
            .expect("revisionRef")
            .to_string()
    }

    async fn block_revision_refs_with_app(app: axum::Router) -> BTreeMap<String, String> {
        let payload = call_tool_ok_with_app(
            app,
            json!({
                "toolName": "mnote.doc.fetch",
                "workspaceId": "ws_demo",
                "documentId": "doc_1",
                "sessionId": "sess_ref_complex",
                "runId": "run_ref_complex",
                "toolCallId": "call_ref_complex",
                "traceId": "trace_ref_complex",
                "capabilityScope": ["page.read"],
                "args": {"scope": "full", "detail": "with_ids"}
            }),
        )
        .await;
        payload["result"]["blocks"]
            .as_array()
            .expect("blocks")
            .iter()
            .filter_map(|block| {
                Some((
                    block["blockId"].as_str()?.to_string(),
                    block["revisionRef"].as_str()?.to_string(),
                ))
            })
            .collect()
    }

    #[tokio::test]
    async fn hermes_tools_call_rejects_declared_scope_that_does_not_cover_tool() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.page.save",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_scope",
                            "runId": "run_scope",
                            "toolCallId": "call_scope",
                            "traceId": "trace_scope",
                            "actorId": "user_1",
                            "capabilityScope": ["page.read"],
                            "dryRun": true,
                            "idempotencyKey": "scope-mismatch",
                            "args": {"content": "不会写入"}
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::FORBIDDEN);
        assert_eq!(
            response
                .headers()
                .get("x-error-code")
                .and_then(|value| value.to_str().ok()),
            Some("mnote_tool_capability_scope_forbidden")
        );
    }

    #[tokio::test]
    async fn hermes_tools_manifest_returns_first_batch_tools() {
        let response = app()
            .oneshot(
                Request::builder()
                    .uri("/api/hermes/tools/mnote/manifest")
                    .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");
        let tools = payload["manifest"]["tools"].as_array().expect("tools");
        assert!(tools.iter().any(|tool| tool["name"] == "mnote.page.get"));
        assert!(tools.iter().any(|tool| tool["name"] == "mnote.page.save"));
        assert!(tools.iter().any(|tool| tool["name"] == "mnote.doc.fetch"));
        assert!(tools.iter().any(|tool| tool["name"] == "mnote.doc.find"));
        assert!(tools.iter().any(|tool| tool["name"] == "mnote.block.fetch"));
        assert!(tools
            .iter()
            .any(|tool| tool["name"] == "mnote.doc.plan_update"));
        assert!(tools
            .iter()
            .any(|tool| tool["name"] == "mnote.block.replace"));
        assert!(tools
            .iter()
            .any(|tool| tool["name"] == "mnote.block.insert_after"));
        assert!(tools
            .iter()
            .any(|tool| tool["name"] == "mnote.block.delete"));
        assert!(tools
            .iter()
            .any(|tool| tool["name"] == "mnote.block.move_after"));
        assert!(tools
            .iter()
            .any(|tool| tool["name"] == "mnote.doc.apply_block_ops"));
        assert!(tools
            .iter()
            .any(|tool| tool["name"] == "mnote.mindmap.fetch"));
        assert!(tools
            .iter()
            .any(|tool| tool["name"] == "mnote.mindmap.apply_ops"));
        assert!(tools
            .iter()
            .any(|tool| tool["name"] == "mnote.office.fetch_summary"));
        assert!(tools
            .iter()
            .any(|tool| tool["name"] == "mnote.office.propose_changes"));
        let page_save = tools
            .iter()
            .find(|tool| tool["name"] == "mnote.page.save")
            .expect("page save tool");
        assert_eq!(page_save["status"], "available");
        assert_eq!(
            payload["manifest"]["schemaVersion"],
            "mnote.hermes_tool_manifest.v1"
        );
    }

    #[tokio::test]
    async fn hermes_tools_manifest_describes_markdown_edit_write_contract() {
        let response = app()
            .oneshot(
                Request::builder()
                    .uri("/api/hermes/tools/mnote/manifest")
                    .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");
        let tools = payload["manifest"]["tools"].as_array().expect("tools");
        let markdown_edit = tools
            .iter()
            .find(|tool| tool["name"] == "mnote.doc.markdown_edit")
            .expect("markdown edit tool");
        let required = markdown_edit["inputSchema"]["required"]
            .as_array()
            .expect("required");
        for field in [
            "workspaceId",
            "documentId",
            "actorId",
            "dryRun",
            "idempotencyKey",
        ] {
            assert!(required.iter().any(|value| value == field));
        }
        assert!(markdown_edit["inputSchema"]["properties"]["operations"].is_object());
        assert!(markdown_edit["inputSchema"]["properties"]["full_content"].is_object());
        assert!(markdown_edit["inputSchema"]["anyOf"]
            .as_array()
            .expect("anyOf")
            .iter()
            .any(|rule| rule["required"] == json!(["operations"])));
        assert!(markdown_edit["inputSchema"]["anyOf"]
            .as_array()
            .expect("anyOf")
            .iter()
            .any(|rule| rule["required"] == json!(["full_content"])));
    }

    #[tokio::test]
    async fn hermes_tools_manifest_marks_write_tools_as_compat_fallbacks() {
        let response = app()
            .oneshot(
                Request::builder()
                    .uri("/api/hermes/tools/mnote/manifest")
                    .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");
        let tools = payload["manifest"]["tools"].as_array().expect("tools");
        let markdown_edit = tools
            .iter()
            .find(|tool| tool["name"] == "mnote.doc.markdown_edit")
            .expect("markdown edit tool");
        let page_save = tools
            .iter()
            .find(|tool| tool["name"] == "mnote.page.save")
            .expect("page save tool");

        assert!(markdown_edit["description"]
            .as_str()
            .expect("description")
            .contains("兼容"));
        assert!(markdown_edit["description"]
            .as_str()
            .expect("description")
            .contains("agent 原生 patch/diff"));
        assert_eq!(
            page_save["annotations"]["requiresWritePermission"],
            Value::Bool(true)
        );
    }

    #[tokio::test]
    async fn hermes_tools_manifest_describes_block_tools_selection_scope() {
        let response = app()
            .oneshot(
                Request::builder()
                    .uri("/api/hermes/tools/mnote/manifest")
                    .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");
        let tools = payload["manifest"]["tools"].as_array().expect("tools");
        for name in [
            "mnote.block.replace",
            "mnote.block.insert_after",
            "mnote.block.delete",
            "mnote.block.move_after",
        ] {
            let tool = tools
                .iter()
                .find(|tool| tool["name"] == name)
                .unwrap_or_else(|| panic!("missing tool {name}"));
            assert!(
                tool["inputSchema"]["properties"]["allowedTargetBlockIds"].is_object(),
                "{name}"
            );
        }
    }

    #[tokio::test]
    async fn hermes_tools_page_get_requires_auth() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .body(Body::from(
                        json!({"toolName":"mnote.page.get","documentId":"doc_1"}).to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
    }

    #[tokio::test]
    async fn hermes_tools_resource_tools_require_allowed_resource_scope() {
        let root =
            std::env::temp_dir().join(format!("mnote-resource-scope-{}", std::process::id()));
        let _ = fs::remove_dir_all(&root);
        fs::create_dir_all(root.join("maps")).expect("maps");
        fs::write(
            root.join("maps").join("idea.mindmap.json"),
            r#"{"data":{"text":"中心主题","uid":"root"},"children":[]}"#,
        )
        .expect("mindmap");
        let root_uri = format!("file://{}", root.display());
        crate::routes::local_folder_source::initialize_local_workspace_for_actor(
            "user_1", &root_uri,
        )
        .expect("workspace");

        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.mindmap.fetch",
                            "workspaceId": "local-ws-resource",
                            "documentId": "local-md:README.md",
                            "sourceKind": "local_folder",
                            "rootUri": root_uri,
                            "actorId": "user_1",
                            "sessionId": "sess_resource_scope",
                            "runId": "run_resource_scope",
                            "toolCallId": "call_resource_scope",
                            "traceId": "trace_resource_scope",
                            "args": {
                                "mindmapId": "mind_allowed",
                                "resourcePath": "maps/idea.mindmap.json",
                                "aiAccessScope": {
                                    "permissionLevel": "shared_read",
                                    "allowedResourceIds": ["mind_other"]
                                }
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::FORBIDDEN);
        assert_eq!(
            response
                .headers()
                .get("x-error-code")
                .and_then(|value| value.to_str().ok()),
            Some("mnote_resource_ai_scope_forbidden")
        );
        let _ = fs::remove_dir_all(&root);
    }

    #[tokio::test]
    async fn hermes_tools_mindmap_fetch_reads_authorized_local_resource() {
        let root = std::env::temp_dir().join(format!(
            "mnote-resource-mindmap-fetch-{}",
            std::process::id()
        ));
        let _ = fs::remove_dir_all(&root);
        fs::create_dir_all(root.join("maps")).expect("maps");
        fs::write(
            root.join("maps").join("idea.mindmap.json"),
            r#"{"data":{"text":"中心主题","uid":"root"},"children":[{"data":{"text":"分支一","uid":"child_1"},"children":[]}]}"#,
        )
        .expect("mindmap");
        let root_uri = format!("file://{}", root.display());
        crate::routes::local_folder_source::initialize_local_workspace_for_actor(
            "user_1", &root_uri,
        )
        .expect("workspace");

        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.mindmap.fetch",
                            "workspaceId": "local-ws-resource",
                            "documentId": "local-md:README.md",
                            "sourceKind": "local_folder",
                            "rootUri": root_uri,
                            "actorId": "user_1",
                            "sessionId": "sess_mindmap_fetch",
                            "runId": "run_mindmap_fetch",
                            "toolCallId": "call_mindmap_fetch",
                            "traceId": "trace_mindmap_fetch",
                            "args": {
                                "mindmapId": "mind_allowed",
                                "resourcePath": "maps/idea.mindmap.json",
                                "aiAccessScope": {
                                    "permissionLevel": "shared_read",
                                    "allowedResourceIds": ["mind_allowed"]
                                }
                            }
                        })
                        .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["toolName"], "mnote.mindmap.fetch");
        assert_eq!(payload["audit"]["effect"], "read");
        assert_eq!(payload["result"]["resourceKind"], "mindmap");
        assert_eq!(
            payload["result"]["objectIdentity"],
            "resource:mindmap:local-md:README.md:mind_allowed"
        );
        assert!(payload["result"]["markdownSummary"]
            .as_str()
            .unwrap_or_default()
            .contains("中心主题"));
        assert_eq!(payload["result"]["nodes"][1]["text"], "分支一");
        let _ = fs::remove_dir_all(&root);
    }

    #[tokio::test]
    async fn hermes_tools_mindmap_apply_ops_shared_read_is_forbidden() {
        let root = std::env::temp_dir().join(format!(
            "mnote-resource-mindmap-write-{}",
            std::process::id()
        ));
        let _ = fs::remove_dir_all(&root);
        fs::create_dir_all(root.join("maps")).expect("maps");
        fs::write(
            root.join("maps").join("idea.mindmap.json"),
            r#"{"data":{"text":"中心主题","uid":"root"},"children":[]}"#,
        )
        .expect("mindmap");
        let root_uri = format!("file://{}", root.display());
        crate::routes::local_folder_source::initialize_local_workspace_for_actor(
            "user_1", &root_uri,
        )
        .expect("workspace");

        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.mindmap.apply_ops",
                            "workspaceId": "local-ws-resource",
                            "documentId": "local-md:README.md",
                            "sourceKind": "local_folder",
                            "rootUri": root_uri,
                            "actorId": "user_1",
                            "sessionId": "sess_mindmap_write",
                            "runId": "run_mindmap_write",
                            "toolCallId": "call_mindmap_write",
                            "traceId": "trace_mindmap_write",
                            "idempotencyKey": "idem_mindmap_write",
                            "dryRun": false,
                            "args": {
                                "mindmapId": "mind_allowed",
                                "resourcePath": "maps/idea.mindmap.json",
                                "ops": [{"op": "update_node", "nodeId": "root", "text": "改名"}],
                                "aiAccessScope": {
                                    "permissionLevel": "shared_read",
                                    "allowedResourceIds": ["mind_allowed"]
                                }
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::FORBIDDEN);
        assert_eq!(
            response
                .headers()
                .get("x-error-code")
                .and_then(|value| value.to_str().ok()),
            Some("mnote_tool_shared_read_write_forbidden")
        );
        let _ = fs::remove_dir_all(&root);
    }

    #[tokio::test]
    async fn hermes_tools_office_fetch_and_propose_changes_do_not_write_file() {
        let root = std::env::temp_dir().join(format!(
            "mnote-resource-office-fetch-{}",
            std::process::id()
        ));
        let _ = fs::remove_dir_all(&root);
        fs::create_dir_all(root.join("office")).expect("office");
        fs::write(root.join("office").join("report.docx"), "Office text").expect("office file");
        let root_uri = format!("file://{}", root.display());
        crate::routes::local_folder_source::initialize_local_workspace_for_actor(
            "user_1", &root_uri,
        )
        .expect("workspace");

        let fetch = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.office.fetch_summary",
                            "workspaceId": "local-ws-resource",
                            "documentId": "local-md:README.md",
                            "sourceKind": "local_folder",
                            "rootUri": root_uri,
                            "actorId": "user_1",
                            "sessionId": "sess_office_fetch",
                            "runId": "run_office_fetch",
                            "toolCallId": "call_office_fetch",
                            "traceId": "trace_office_fetch",
                            "args": {
                                "assetId": "asset_report",
                                "resourcePath": "office/report.docx",
                                "aiAccessScope": {
                                    "permissionLevel": "shared_read",
                                    "allowedResourceIds": ["resource:onlyoffice:local-md:README.md:asset_report"]
                                }
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(fetch.status(), StatusCode::OK);
        let fetch_body = to_bytes(fetch.into_body(), usize::MAX)
            .await
            .expect("fetch body");
        let fetch_payload: Value = serde_json::from_slice(&fetch_body).expect("fetch json");
        assert_eq!(fetch_payload["audit"]["effect"], "read");
        assert_eq!(fetch_payload["result"]["resourceKind"], "only_office");
        assert_eq!(fetch_payload["result"]["fileName"], "report.docx");

        let before = fs::read(root.join("office").join("report.docx")).expect("before");
        let propose = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.office.propose_changes",
                            "workspaceId": "local-ws-resource",
                            "documentId": "local-md:README.md",
                            "sourceKind": "local_folder",
                            "rootUri": root_uri,
                            "actorId": "user_1",
                            "sessionId": "sess_office_propose",
                            "runId": "run_office_propose",
                            "toolCallId": "call_office_propose",
                            "traceId": "trace_office_propose",
                            "args": {
                                "assetId": "asset_report",
                                "resourcePath": "office/report.docx",
                                "instructions": "补充结论",
                                "aiAccessScope": {
                                    "permissionLevel": "shared_read",
                                    "allowedResourceIds": ["asset_report"]
                                }
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(propose.status(), StatusCode::OK);
        let propose_body = to_bytes(propose.into_body(), usize::MAX)
            .await
            .expect("propose body");
        let propose_payload: Value = serde_json::from_slice(&propose_body).expect("propose json");
        assert_eq!(propose_payload["audit"]["effect"], "read");
        assert_eq!(propose_payload["result"]["writesBinary"], false);
        assert_eq!(
            fs::read(root.join("office").join("report.docx")).expect("after"),
            before
        );
        let _ = fs::remove_dir_all(&root);
    }

    #[tokio::test]
    async fn hermes_tools_page_get_accepts_delegated_actor_from_hermes_payload() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.page.get",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "actorId": "user_hermes",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_1",
                            "traceId": "trace_1",
                            "capabilityScope": ["page.read"]
                        })
                        .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["audit"]["actorId"], "user_hermes");
        assert_eq!(payload["result"]["title"], "服务端页面");
    }

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

        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.page.get",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_disabled",
                            "runId": "run_disabled",
                            "toolCallId": "call_disabled",
                            "traceId": "trace_disabled",
                            "profile": "blocked",
                            "capabilityScope": ["page.read"]
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::FORBIDDEN);
        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"], "mnote_tool_disabled");

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

    #[tokio::test]
    async fn hermes_tools_write_tools_require_auth() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.page.save",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_1",
                            "traceId": "trace_1",
                            "idempotencyKey": "idem_1",
                            "dryRun": false,
                            "args": {"content": []}
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
    }

    #[tokio::test]
    async fn hermes_tools_page_get_returns_page_aggregate_summary() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.page.get",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_1",
                            "traceId": "trace_1",
                            "capabilityScope": ["page.read"]
                        })
                        .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["toolName"], "mnote.page.get");
        assert_eq!(payload["toolCallId"], "call_1");
        assert_eq!(payload["result"]["title"], "服务端页面");
        assert!(payload["result"]["bodySummary"]
            .as_str()
            .unwrap_or_default()
            .contains("章节一"));
        assert_eq!(payload["audit"]["effect"], "read");
    }

    #[tokio::test]
    async fn hermes_tools_doc_fetch_returns_block_projection() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.doc.fetch",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_doc_fetch_1",
                            "traceId": "trace_doc_fetch_1",
                            "capabilityScope": ["page.read"],
                            "args": {"scope": "full", "detail": "with_ids"}
                        })
                        .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["toolName"], "mnote.doc.fetch");
        assert_eq!(payload["audit"]["effect"], "read");
        assert_eq!(payload["result"]["revision"], json!(7));
        assert_eq!(payload["result"]["conflictDetectionKey"], json!("doc_1:7"));
        assert_eq!(payload["result"]["fileVersion"], json!("doc_1:7"));
        assert_eq!(
            payload["result"]["blocks"][0]["blockId"],
            json!("heading_1")
        );
        assert_eq!(payload["result"]["blocks"][0]["text"], json!("章节一"));
        assert!(payload["result"]["blocks"][0]["revisionRef"]
            .as_str()
            .unwrap_or_default()
            .starts_with("pageRev:7:block:heading_1:hash:"));
    }

    #[tokio::test]
    async fn hermes_tools_doc_fetch_rejects_out_of_scope_ai_resource() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.doc.fetch",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_2",
                            "sessionId": "sess_scope_read",
                            "runId": "run_scope_read",
                            "toolCallId": "call_scope_read",
                            "traceId": "trace_scope_read",
                            "args": {
                                "aiAccessScope": {
                                    "permissionLevel": "shared_read",
                                    "allowedResourceIds": ["doc_1"],
                                    "shareContext": {"shareId": "share_read_1"}
                                }
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::FORBIDDEN);
        assert_eq!(
            response
                .headers()
                .get("x-error-code")
                .and_then(|value| value.to_str().ok()),
            Some("mnote_tool_ai_scope_read_forbidden")
        );
    }

    #[tokio::test]
    async fn hermes_tools_page_get_rejects_out_of_scope_ai_resource() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.page.get",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_2",
                            "sessionId": "sess_page_scope_read",
                            "runId": "run_page_scope_read",
                            "toolCallId": "call_page_scope_read",
                            "traceId": "trace_page_scope_read",
                            "args": {
                                "aiAccessScope": {
                                    "permissionLevel": "shared_read",
                                    "allowedResourceIds": ["doc_1"],
                                    "shareContext": {"shareId": "share_read_1"}
                                }
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::FORBIDDEN);
        assert_eq!(
            response
                .headers()
                .get("x-error-code")
                .and_then(|value| value.to_str().ok()),
            Some("mnote_tool_ai_scope_read_forbidden")
        );
    }

    #[tokio::test]
    async fn hermes_tools_doc_fetch_supports_selection_and_page_xml() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.doc.fetch",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_doc_fetch_selection_1",
                            "traceId": "trace_doc_fetch_selection_1",
                            "capabilityScope": ["page.read"],
                            "args": {
                                "scope": "selection",
                                "selectedBlockIds": ["heading_1"],
                                "format": "page_xml"
                            }
                        })
                        .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["result"]["schema"], "mnote.page_ai_context.v1");
        assert_eq!(payload["result"]["scope"], "selection");
        assert_eq!(payload["result"]["format"], "page_xml");
        assert_eq!(payload["result"]["blocks"].as_array().unwrap().len(), 1);
        assert!(payload["result"]["content"]
            .as_str()
            .unwrap_or_default()
            .contains("<block id=\"heading_1\""));
        assert_eq!(payload["result"]["allowedTargetBlockIds"][0], "heading_1");
    }

    #[tokio::test]
    async fn hermes_tools_doc_find_and_block_fetch_use_block_projection() {
        let find_response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.doc.find",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_doc_find_1",
                            "traceId": "trace_doc_find_1",
                            "capabilityScope": ["page.read"],
                            "args": {"query": "章节一"}
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(find_response.status(), StatusCode::OK);
        let find_body = to_bytes(find_response.into_body(), usize::MAX)
            .await
            .expect("body");
        let find_payload: Value = serde_json::from_slice(&find_body).expect("json");
        assert_eq!(
            find_payload["result"]["matches"][0]["blockId"],
            json!("heading_1")
        );

        let fetch_response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.block.fetch",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_block_fetch_1",
                            "traceId": "trace_block_fetch_1",
                            "capabilityScope": ["block.read", "page.read"],
                            "args": {"blockId": "heading_1", "contextBefore": 1, "contextAfter": 1}
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(fetch_response.status(), StatusCode::OK);
        let fetch_body = to_bytes(fetch_response.into_body(), usize::MAX)
            .await
            .expect("body");
        let fetch_payload: Value = serde_json::from_slice(&fetch_body).expect("json");
        assert_eq!(
            fetch_payload["result"]["block"]["blockId"],
            json!("heading_1")
        );
        assert_eq!(fetch_payload["result"]["block"]["text"], json!("章节一"));
        assert_eq!(fetch_payload["audit"]["effect"], "read");
    }

    #[tokio::test]
    async fn hermes_tools_plan_update_and_block_move_after_are_dry_run_only() {
        let heading_ref = block_revision_ref("heading_1").await;
        let plan_response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.doc.plan_update",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_plan_1",
                            "traceId": "trace_plan_1",
                            "idempotencyKey": "idem_plan_1",
                            "dryRun": true,
                            "capabilityScope": ["page.write", "block.write"],
                            "args": {
                                "command": "block_replace",
                                "blockId": "heading_1",
                                "content": "替换标题"
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(plan_response.status(), StatusCode::OK);
        let plan_body = to_bytes(plan_response.into_body(), usize::MAX)
            .await
            .expect("body");
        let plan_payload: Value = serde_json::from_slice(&plan_body).expect("json");
        assert_eq!(plan_payload["audit"]["effect"], "dry_run");
        assert_eq!(plan_payload["result"]["diff"][0]["op"], "replace");

        let move_response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.block.move_after",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_move_1",
                            "traceId": "trace_move_1",
                            "idempotencyKey": "idem_move_1",
                            "dryRun": false,
                            "capabilityScope": ["block.write", "page.write"],
                            "args": {
                                "blockId": "heading_1",
                                "anchorBlockId": "heading_1",
                                "revision": 7,
                                "conflictDetectionKey": "doc_1:7",
                                "blockRevisionRef": heading_ref.clone(),
                                "anchorRevisionRef": heading_ref.clone()
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(move_response.status(), StatusCode::OK);
        let move_body = to_bytes(move_response.into_body(), usize::MAX)
            .await
            .expect("body");
        let move_payload: Value = serde_json::from_slice(&move_body).expect("json");
        assert_eq!(move_payload["result"]["blocked"], true);
        assert_eq!(
            move_payload["result"]["warnings"][0]["code"],
            "block_move_after_blocked"
        );
    }

    #[tokio::test]
    async fn hermes_tools_block_replace_and_insert_after_write_through_page_body_save() {
        let heading_ref = block_revision_ref("heading_1").await;
        let p1_ref = block_revision_ref("p_1").await;
        let p2_ref = block_revision_ref("p_2").await;
        let replace_response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.block.replace",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_replace_1",
                            "traceId": "trace_replace_1",
                            "idempotencyKey": "idem_replace_1",
                            "dryRun": false,
                            "capabilityScope": ["block.write", "page.write"],
                            "args": {
                                "blockId": "heading_1",
                                "content": "替换后的章节",
                                "revision": 7,
                                "conflictDetectionKey": "doc_1:7",
                                "blockRevisionRef": heading_ref.clone()
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(replace_response.status(), StatusCode::OK);
        let replace_body = to_bytes(replace_response.into_body(), usize::MAX)
            .await
            .expect("body");
        let replace_payload: Value = serde_json::from_slice(&replace_body).expect("json");
        assert_eq!(replace_payload["audit"]["effect"], "write");
        assert_eq!(
            replace_payload["result"]["changedBlocks"][0]["blockId"],
            json!("heading_1")
        );
        assert_eq!(
            replace_payload["result"]["commandName"],
            json!("page.body.save")
        );

        let insert_response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.block.insert_after",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_insert_1",
                            "traceId": "trace_insert_1",
                            "idempotencyKey": "idem_insert_1",
                            "dryRun": false,
                            "capabilityScope": ["block.write", "page.write"],
                            "args": {
                                "anchorBlockId": "heading_1",
                                "content": "新增段落",
                                "revision": 7,
                                "conflictDetectionKey": "doc_1:7",
                                "anchorRevisionRef": heading_ref.clone()
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(insert_response.status(), StatusCode::OK);
        let insert_body = to_bytes(insert_response.into_body(), usize::MAX)
            .await
            .expect("body");
        let insert_payload: Value = serde_json::from_slice(&insert_body).expect("json");
        assert_eq!(insert_payload["audit"]["effect"], "write");
        assert_eq!(
            insert_payload["result"]["changedBlocks"][0]["op"],
            json!("insert_after")
        );
        assert!(insert_payload["result"]["changedBlocks"][0]["blockId"]
            .as_str()
            .unwrap_or_default()
            .starts_with("ai_block_"));

        let delete_response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.block.delete",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_delete_1",
                            "traceId": "trace_delete_1",
                            "idempotencyKey": "idem_delete_1",
                            "dryRun": false,
                            "capabilityScope": ["block.write", "page.write"],
                            "args": {
                                "blockId": "p_1",
                                "revision": 7,
                                "conflictDetectionKey": "doc_1:7",
                                "blockRevisionRef": p1_ref.clone()
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(delete_response.status(), StatusCode::OK);
        let delete_body = to_bytes(delete_response.into_body(), usize::MAX)
            .await
            .expect("body");
        let delete_payload: Value = serde_json::from_slice(&delete_body).expect("json");
        assert_eq!(delete_payload["audit"]["effect"], "write");
        assert_eq!(
            delete_payload["result"]["changedBlocks"][0]["op"],
            json!("delete")
        );
        assert_eq!(
            delete_payload["result"]["changedBlocks"][0]["blockId"],
            json!("p_1")
        );

        let move_response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.block.move_after",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_move_write_1",
                            "traceId": "trace_move_write_1",
                            "idempotencyKey": "idem_move_write_1",
                            "dryRun": false,
                            "capabilityScope": ["block.write", "page.write"],
                            "args": {
                                "blockId": "heading_1",
                                "anchorBlockId": "p_2",
                                "revision": 7,
                                "conflictDetectionKey": "doc_1:7",
                                "blockRevisionRef": heading_ref.clone(),
                                "anchorRevisionRef": p2_ref.clone()
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(move_response.status(), StatusCode::OK);
        let move_body = to_bytes(move_response.into_body(), usize::MAX)
            .await
            .expect("body");
        let move_payload: Value = serde_json::from_slice(&move_body).expect("json");
        assert_eq!(move_payload["audit"]["effect"], "write");
        assert_eq!(
            move_payload["result"]["changedBlocks"][0]["op"],
            json!("move_after")
        );
    }

    #[tokio::test]
    async fn hermes_tools_block_tools_selection_scope_rejects_out_of_scope_targets() {
        let heading_ref = block_revision_ref("heading_1").await;
        let p1_ref = block_revision_ref("p_1").await;
        let p2_ref = block_revision_ref("p_2").await;
        let cases = vec![
            json!({
                "toolName": "mnote.block.replace",
                "workspaceId": "ws_demo",
                "documentId": "doc_1",
                "sessionId": "sess_scope_replace",
                "runId": "run_scope_replace",
                "toolCallId": "call_scope_replace",
                "traceId": "trace_scope_replace",
                "idempotencyKey": "idem_scope_replace",
                "dryRun": false,
                "capabilityScope": ["block.write", "page.write"],
                "args": {
                    "blockId": "heading_1",
                    "content": "越界替换",
                    "revision": 7,
                    "conflictDetectionKey": "doc_1:7",
                    "blockRevisionRef": heading_ref,
                    "allowedTargetBlockIds": ["p_1"]
                }
            }),
            json!({
                "toolName": "mnote.block.insert_after",
                "workspaceId": "ws_demo",
                "documentId": "doc_1",
                "sessionId": "sess_scope_insert",
                "runId": "run_scope_insert",
                "toolCallId": "call_scope_insert",
                "traceId": "trace_scope_insert",
                "idempotencyKey": "idem_scope_insert",
                "dryRun": false,
                "capabilityScope": ["block.write", "page.write"],
                "args": {
                    "anchorBlockId": "heading_1",
                    "content": "越界插入",
                    "revision": 7,
                    "conflictDetectionKey": "doc_1:7",
                    "anchorRevisionRef": heading_ref,
                    "allowedTargetBlockIds": ["p_1"]
                }
            }),
            json!({
                "toolName": "mnote.block.delete",
                "workspaceId": "ws_demo",
                "documentId": "doc_1",
                "sessionId": "sess_scope_delete",
                "runId": "run_scope_delete",
                "toolCallId": "call_scope_delete",
                "traceId": "trace_scope_delete",
                "idempotencyKey": "idem_scope_delete",
                "dryRun": false,
                "capabilityScope": ["block.write", "page.write"],
                "args": {
                    "blockId": "p_1",
                    "revision": 7,
                    "conflictDetectionKey": "doc_1:7",
                    "blockRevisionRef": p1_ref,
                    "allowedTargetBlockIds": ["heading_1"]
                }
            }),
            json!({
                "toolName": "mnote.block.move_after",
                "workspaceId": "ws_demo",
                "documentId": "doc_1",
                "sessionId": "sess_scope_move",
                "runId": "run_scope_move",
                "toolCallId": "call_scope_move",
                "traceId": "trace_scope_move",
                "idempotencyKey": "idem_scope_move",
                "dryRun": false,
                "capabilityScope": ["block.write", "page.write"],
                "args": {
                    "blockId": "heading_1",
                    "anchorBlockId": "p_2",
                    "revision": 7,
                    "conflictDetectionKey": "doc_1:7",
                    "blockRevisionRef": heading_ref,
                    "anchorRevisionRef": p2_ref,
                    "allowedTargetBlockIds": ["heading_1"]
                }
            }),
        ];

        for payload in cases {
            let response = app()
                .oneshot(
                    Request::builder()
                        .method("POST")
                        .uri("/api/hermes/tools/mnote/call")
                        .header("content-type", "application/json")
                        .header("x-mnote-actor-id", "user_1")
                        .body(Body::from(payload.to_string()))
                        .expect("request"),
                )
                .await
                .expect("response");
            assert_eq!(response.status(), StatusCode::BAD_REQUEST);
            assert_eq!(
                response
                    .headers()
                    .get("x-error-code")
                    .and_then(|value| value.to_str().ok()),
                Some("mnote_block_target_out_of_scope")
            );
        }
    }

    #[tokio::test]
    async fn hermes_tools_block_move_after_blocks_complex_and_nested_blocks() {
        let content = json!([
            {
                "id": "heading_parent",
                "type": "heading",
                "props": { "level": 2 },
                "content": [{ "type": "text", "text": "带子块标题" }],
                "children": [
                    {
                        "id": "heading_child",
                        "type": "paragraph",
                        "content": [{ "type": "text", "text": "标题子段落" }]
                    }
                ]
            },
            {
                "id": "p_anchor",
                "type": "paragraph",
                "content": [{ "type": "text", "text": "锚点段落" }]
            },
            {
                "id": "list_item_1",
                "type": "bullet_list_item",
                "content": [{ "type": "text", "text": "列表项" }]
            },
            {
                "id": "table_1",
                "type": "table",
                "content": [{ "type": "text", "text": "表格块" }]
            },
            {
                "id": "mindmap_1",
                "type": "mindmap",
                "props": { "mindmapId": "mindmap_fixture_1" },
                "content": [{ "type": "text", "text": "思维导图块" }]
            },
            {
                "id": "resource_1",
                "type": "resource",
                "content": [{ "type": "text", "text": "资源块" }]
            }
        ]);
        let refs = block_revision_refs_with_app(app_with_content(content.clone())).await;
        let anchor_ref = refs.get("p_anchor").expect("anchor ref").clone();
        for block_id in [
            "heading_parent",
            "list_item_1",
            "table_1",
            "mindmap_1",
            "resource_1",
        ] {
            let response = app_with_content(content.clone())
                .oneshot(
                    Request::builder()
                        .method("POST")
                        .uri("/api/hermes/tools/mnote/call")
                        .header("content-type", "application/json")
                        .header("x-mnote-actor-id", "user_1")
                        .body(Body::from(
                            json!({
                                "toolName": "mnote.block.move_after",
                                "workspaceId": "ws_demo",
                                "documentId": "doc_1",
                                "sessionId": format!("sess_move_blocked_{block_id}"),
                                "runId": format!("run_move_blocked_{block_id}"),
                                "toolCallId": format!("call_move_blocked_{block_id}"),
                                "traceId": format!("trace_move_blocked_{block_id}"),
                                "idempotencyKey": format!("idem_move_blocked_{block_id}"),
                                "dryRun": false,
                                "capabilityScope": ["block.write", "page.write"],
                                "args": {
                                    "blockId": block_id,
                                    "anchorBlockId": "p_anchor",
                                    "revision": 7,
                                    "conflictDetectionKey": "doc_1:7",
                                    "blockRevisionRef": refs.get(block_id).expect("block ref"),
                                    "anchorRevisionRef": anchor_ref
                                }
                            })
                            .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["result"]["blocked"], json!(true), "{block_id}");
            assert_eq!(
                payload["result"]["warnings"][0]["code"],
                json!("block_move_after_blocked"),
                "{block_id}"
            );
        }
    }

    #[tokio::test]
    async fn hermes_tools_block_insert_after_accepts_multiple_blocks_and_returns_ids() {
        let heading_ref = block_revision_ref("heading_1").await;
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.block.insert_after",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_multi_insert",
                            "runId": "run_multi_insert",
                            "toolCallId": "call_multi_insert",
                            "traceId": "trace_multi_insert",
                            "idempotencyKey": "idem_multi_insert",
                            "dryRun": false,
                            "capabilityScope": ["block.write", "page.write"],
                            "args": {
                                "anchorBlockId": "heading_1",
                                "blocks": ["新增第一段", {"type": "todo", "content": "新增待办"}],
                                "revision": 7,
                                "conflictDetectionKey": "doc_1:7",
                                "anchorRevisionRef": heading_ref
                            }
                        })
                        .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");
        let inserted_ids = payload["result"]["insertedBlockIds"]
            .as_array()
            .expect("insertedBlockIds");
        assert_eq!(inserted_ids.len(), 2);
        assert!(inserted_ids
            .iter()
            .all(|id| { id.as_str().unwrap_or_default().starts_with("ai_block_") }));
        assert_eq!(
            payload["result"]["changedBlocks"]
                .as_array()
                .expect("changedBlocks")
                .len(),
            2
        );
        assert_eq!(payload["result"]["changedBlocks"][0]["op"], "insert_after");
        assert_eq!(payload["result"]["changedBlocks"][1]["op"], "insert_after");
    }

    #[tokio::test]
    async fn hermes_tools_block_insert_after_rejects_more_than_twenty_blocks() {
        let heading_ref = block_revision_ref("heading_1").await;
        let blocks = (0..21)
            .map(|index| json!(format!("新增段落 {index}")))
            .collect::<Vec<_>>();
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.block.insert_after",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_multi_insert_limit",
                            "runId": "run_multi_insert_limit",
                            "toolCallId": "call_multi_insert_limit",
                            "traceId": "trace_multi_insert_limit",
                            "idempotencyKey": "idem_multi_insert_limit",
                            "dryRun": false,
                            "capabilityScope": ["block.write", "page.write"],
                            "args": {
                                "anchorBlockId": "heading_1",
                                "blocks": blocks,
                                "revision": 7,
                                "conflictDetectionKey": "doc_1:7",
                                "anchorRevisionRef": heading_ref
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
        assert_eq!(
            response
                .headers()
                .get("x-error-code")
                .and_then(|value| value.to_str().ok()),
            Some("mnote_tool_bad_request")
        );
    }

    #[tokio::test]
    async fn hermes_tools_block_write_requires_fresh_revision_and_block_ref() {
        let missing_revision_response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.block.replace",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_missing_revision_1",
                            "traceId": "trace_missing_revision_1",
                            "idempotencyKey": "idem_missing_revision_1",
                            "dryRun": false,
                            "capabilityScope": ["block.write", "page.write"],
                            "args": {
                                "blockId": "heading_1",
                                "content": "不应写入"
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(missing_revision_response.status(), StatusCode::BAD_REQUEST);
        assert_eq!(
            missing_revision_response
                .headers()
                .get("x-error-code")
                .and_then(|value| value.to_str().ok()),
            Some("mnote_tool_write_precondition_required")
        );

        let stale_ref_response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.block.replace",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_stale_ref_1",
                            "traceId": "trace_stale_ref_1",
                            "idempotencyKey": "idem_stale_ref_1",
                            "dryRun": false,
                            "capabilityScope": ["block.write", "page.write"],
                            "args": {
                                "blockId": "heading_1",
                                "content": "不应写入",
                                "revision": 7,
                                "conflictDetectionKey": "doc_1:7",
                                "blockRevisionRef": "pageRev:old:block:heading_1:hash:stale"
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(stale_ref_response.status(), StatusCode::BAD_REQUEST);
        assert_eq!(
            stale_ref_response
                .headers()
                .get("x-error-code")
                .and_then(|value| value.to_str().ok()),
            Some("mnote_tool_conflict")
        );
    }

    #[tokio::test]
    async fn hermes_tools_apply_block_ops_requires_page_preconditions() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.doc.apply_block_ops",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_apply_missing_revision_1",
                            "traceId": "trace_apply_missing_revision_1",
                            "idempotencyKey": "idem_apply_missing_revision_1",
                            "dryRun": false,
                            "capabilityScope": ["block.write", "page.write"],
                            "args": {
                                "operations": [{
                                    "op": "replace",
                                    "blockId": "p_2",
                                    "content": "不应写入"
                                }]
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
        assert_eq!(
            response
                .headers()
                .get("x-error-code")
                .and_then(|value| value.to_str().ok()),
            Some("mnote_tool_write_precondition_required")
        );

        let missing_block_ref_response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.doc.apply_block_ops",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_apply_missing_block_ref_1",
                            "traceId": "trace_apply_missing_block_ref_1",
                            "idempotencyKey": "idem_apply_missing_block_ref_1",
                            "dryRun": false,
                            "capabilityScope": ["block.write", "page.write"],
                            "args": {
                                "revision": 7,
                                "conflictDetectionKey": "doc_1:7",
                                "operations": [{
                                    "op": "replace",
                                    "blockId": "p_2",
                                    "content": "不应写入"
                                }]
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(missing_block_ref_response.status(), StatusCode::BAD_REQUEST);
        assert_eq!(
            missing_block_ref_response
                .headers()
                .get("x-error-code")
                .and_then(|value| value.to_str().ok()),
            Some("mnote_tool_write_precondition_required")
        );
    }

    #[tokio::test]
    async fn hermes_tools_reject_workspace_context_conflict_without_content_leak() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .header("x-mnote-workspace-id", "ws_other")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.page.get",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_1",
                            "traceId": "trace_1",
                            "capabilityScope": ["page.read"]
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::FORBIDDEN);
        assert_eq!(
            response
                .headers()
                .get("x-error-code")
                .and_then(|value| value.to_str().ok()),
            Some("workspace_context_conflict")
        );
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let text = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(!text.contains("服务端页面"));
        assert!(!text.contains("章节一"));
    }

    #[tokio::test]
    async fn hermes_tools_write_tools_require_idempotency_and_dry_run_flag() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.page.save",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_1",
                            "traceId": "trace_1",
                            "args": {"content": []}
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
        assert_eq!(
            response
                .headers()
                .get("x-error-code")
                .and_then(|value| value.to_str().ok()),
            Some("mnote_tool_idempotency_required")
        );
    }

    #[tokio::test]
    async fn hermes_tools_page_save_local_folder_writes_markdown_file() {
        let _env_guard = env_lock().lock().expect("env lock");
        let root = std::env::temp_dir().join(format!(
            "mnote-page-save-local-folder-{}",
            std::process::id()
        ));
        let audit_dir = std::env::temp_dir().join(format!(
            "mnote-local-agent-audit-tool-write-{}",
            std::process::id()
        ));
        let _ = fs::remove_dir_all(&root);
        let _ = fs::remove_dir_all(&audit_dir);
        std::env::set_var("MNOTE_LOCAL_AGENT_AUDIT_DIR", &audit_dir);
        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"]}"#,
        )
        .expect("manifest");
        fs::write(root.join("README.md"), "# Old\n\n旧正文\n").expect("markdown");
        let root_uri = format!("file://{}", root.display());

        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.page.save",
                            "workspaceId": "local-ws-user-1",
                            "documentId": "local-md:README.md",
                            "sourceKind": "local_folder",
                            "rootUri": root_uri,
                            "sessionId": "sess_page_save_local",
                            "runId": "run_page_save_local",
                            "toolCallId": "call_page_save_local",
                            "traceId": "trace_page_save_local",
                            "idempotencyKey": "idem_page_save_local",
                            "dryRun": false,
                            "args": {
                                "content": [
                                    {"type": "paragraph", "content": [{"type": "text", "text": "本地 page.save 写入"}]}
                                ]
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");

        let status = response.status();
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let text = String::from_utf8(body.to_vec()).expect("utf8");
        assert_eq!(status, StatusCode::OK, "{text}");
        let payload: Value = serde_json::from_str(&text).expect("json");
        assert_eq!(payload["result"]["source"], "local_folder");
        assert_eq!(payload["result"]["commandName"], "page.body.write");
        let saved = fs::read_to_string(root.join("README.md")).expect("read");
        assert!(saved.contains("本地 page.save 写入"), "{saved}");
        let jsonl = fs::read_to_string(audit_dir.join("agent-audit.jsonl")).expect("audit jsonl");
        let event = jsonl
            .lines()
            .filter(|line| !line.trim().is_empty())
            .filter_map(|line| serde_json::from_str::<Value>(line).ok())
            .find(|event| event["runId"] == "run_page_save_local")
            .expect("audit event");
        assert_eq!(event["origin"], "mnote_tool");
        assert_eq!(event["toolName"], "mnote.page.save");
        assert_eq!(event["toolCallId"], "call_page_save_local");
        assert_eq!(event["writeAttemptRejected"], false);
        assert_eq!(event["changedFiles"][0]["path"], "local-md:README.md");
        assert_eq!(event["changedFiles"][0]["changeType"], "modified");
        std::env::remove_var("MNOTE_LOCAL_AGENT_AUDIT_DIR");
        let _ = fs::remove_dir_all(&root);
        let _ = fs::remove_dir_all(&audit_dir);
    }

    #[tokio::test]
    async fn hermes_tools_update_options_local_folder_stores_ui_preferences_in_sqlite() {
        let root = std::env::temp_dir().join(format!(
            "mnote-page-options-local-folder-{}",
            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"]}"#,
        )
        .expect("manifest");
        fs::write(root.join("README.md"), "# Old\n\n旧正文\n").expect("markdown");
        let root_uri = format!("file://{}", root.display());

        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.page.update_options",
                            "workspaceId": "local-ws-user-1",
                            "documentId": "local-md:README.md",
                            "sourceKind": "local_folder",
                            "rootUri": root_uri,
                            "sessionId": "sess_page_options_local",
                            "runId": "run_page_options_local",
                            "toolCallId": "call_page_options_local",
                            "traceId": "trace_page_options_local",
                            "idempotencyKey": "idem_page_options_local",
                            "dryRun": false,
                            "args": {
                                "options": {
                                    "wideLayout": true,
                                    "showHeadingNumbers": true,
                                    "hideTitleHeader": false
                                }
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");

        let status = response.status();
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let text = String::from_utf8(body.to_vec()).expect("utf8");
        assert_eq!(status, StatusCode::OK, "{text}");
        let payload: Value = serde_json::from_str(&text).expect("json");
        assert_eq!(payload["result"]["source"], "local_folder");
        assert_eq!(
            payload["result"]["commandName"],
            "page.layout.updateOptions"
        );
        assert_eq!(
            payload["result"]["result"]["pageOptions"]["wideLayout"],
            true
        );
        assert_eq!(
            payload["result"]["result"]["pageOptions"]["showHeadingNumbers"],
            true
        );
        assert_eq!(
            payload["result"]["result"]["pageOptions"]["hideTitleHeader"],
            false
        );
        assert!(
            !root.join(".mnote").join("page-options.json").exists(),
            "AI 页面设置工具不应继续写入 .mnote/page-options.json"
        );
        let _ = fs::remove_dir_all(&root);
    }

    #[tokio::test]
    async fn hermes_tools_page_save_dry_run_returns_diff_without_write() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.page.save",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_1",
                            "traceId": "trace_1",
                            "idempotencyKey": "idem_save_1",
                            "dryRun": true,
                            "args": {"content": [{"type":"paragraph","content":[{"type":"text","text":"AI 写入"}]}]}
                        })
                        .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["audit"]["effect"], "dry_run");
        assert_eq!(payload["result"]["dryRun"], true);
        assert_eq!(payload["result"]["commandName"], "page.body.save");
    }

    #[tokio::test]
    async fn hermes_tools_markdown_edit_local_requires_write_contract() {
        let root = std::env::temp_dir().join(format!(
            "mnote-markdown-edit-contract-{}",
            std::process::id()
        ));
        let _ = fs::remove_dir_all(&root);
        fs::create_dir_all(&root).expect("create temp root");
        let path = root.join("page.md");
        fs::write(&path, "第一段\n\n第二段\n").expect("write markdown");

        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.doc.markdown_edit",
                            "workspaceId": "ws_demo",
                            "documentId": path.to_string_lossy(),
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_1",
                            "traceId": "trace_1",
                            "dryRun": false,
                            "args": {
                                "operations": [{"search": "第二段", "replace": "测试123"}]
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
        assert_eq!(
            response
                .headers()
                .get("x-error-code")
                .and_then(|value| value.to_str().ok()),
            Some("mnote_tool_idempotency_required")
        );
        assert_eq!(
            fs::read_to_string(&path).expect("read markdown"),
            "第一段\n\n第二段\n"
        );
        let _ = fs::remove_dir_all(&root);
    }

    #[tokio::test]
    async fn hermes_tools_markdown_edit_local_dry_run_does_not_write() {
        let root = std::env::temp_dir().join(format!(
            "mnote-markdown-edit-dry-run-{}",
            std::process::id()
        ));
        let _ = fs::remove_dir_all(&root);
        fs::create_dir_all(&root).expect("create temp root");
        let path = root.join("page.md");
        fs::write(&path, "第一段\n\n第二段\n").expect("write markdown");

        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.doc.markdown_edit",
                            "workspaceId": "ws_demo",
                            "documentId": path.to_string_lossy(),
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_1",
                            "traceId": "trace_1",
                            "idempotencyKey": "idem_markdown_local_1",
                            "dryRun": true,
                            "args": {
                                "operations": [{"search": "第二段", "replace": "测试123"}]
                            }
                        })
                        .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["audit"]["effect"], "dry_run");
        assert_eq!(payload["result"]["operationsApplied"], 1);
        assert_eq!(payload["result"]["applyResult"]["written"], false);
        assert_eq!(payload["result"]["applyResult"]["dryRun"], true);
        assert_eq!(
            fs::read_to_string(&path).expect("read markdown"),
            "第一段\n\n第二段\n"
        );
        let _ = fs::remove_dir_all(&root);
    }

    #[tokio::test]
    async fn hermes_tools_markdown_edit_shared_read_is_forbidden() {
        let _env_guard = env_lock().lock().expect("env lock");
        let root = std::env::temp_dir().join(format!(
            "mnote-markdown-edit-shared-read-{}",
            std::process::id()
        ));
        let audit_dir = std::env::temp_dir().join(format!(
            "mnote-local-agent-audit-shared-read-{}",
            std::process::id()
        ));
        let _ = fs::remove_dir_all(&root);
        let _ = fs::remove_dir_all(&audit_dir);
        std::env::set_var("MNOTE_LOCAL_AGENT_AUDIT_DIR", &audit_dir);
        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");
        fs::write(root.join("README.md"), "原文\n").expect("write markdown");
        let root_uri = format!("file://{}", root.display());

        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.doc.markdown_edit",
                            "workspaceId": "local-ws-user-1",
                            "documentId": "local-md:README.md",
                            "sourceKind": "local_folder",
                            "rootUri": root_uri,
                            "actorId": "user_1",
                            "sessionId": "sess_shared_read_md",
                            "runId": "run_shared_read_md",
                            "toolCallId": "call_shared_read_md",
                            "traceId": "trace_shared_read_md",
                            "idempotencyKey": "idem_shared_read_md",
                            "dryRun": false,
                            "args": {
                                "aiAccessScope": {
                                    "permissionLevel": "shared_read",
                                    "shareContext": {"shareId": "share_read_1"}
                                },
                                "operations": [{"search": "原文", "replace": "不应写入"}]
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::FORBIDDEN);
        assert_eq!(
            response
                .headers()
                .get("x-error-code")
                .and_then(|value| value.to_str().ok()),
            Some("mnote_tool_shared_read_write_forbidden")
        );
        assert_eq!(
            fs::read_to_string(root.join("README.md")).expect("read"),
            "原文\n"
        );
        std::env::remove_var("MNOTE_LOCAL_AGENT_AUDIT_DIR");
        let _ = fs::remove_dir_all(&root);
        let _ = fs::remove_dir_all(&audit_dir);
    }

    #[tokio::test]
    async fn hermes_tools_markdown_edit_shared_read_rejection_writes_local_agent_audit() {
        let _env_guard = env_lock().lock().expect("env lock");
        let root = std::env::temp_dir().join(format!(
            "mnote-markdown-edit-shared-read-audit-{}",
            std::process::id()
        ));
        let audit_dir = std::env::temp_dir().join(format!(
            "mnote-local-agent-audit-rejected-{}",
            std::process::id()
        ));
        let _ = fs::remove_dir_all(&root);
        let _ = fs::remove_dir_all(&audit_dir);
        std::env::set_var("MNOTE_LOCAL_AGENT_AUDIT_DIR", &audit_dir);
        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");
        fs::write(root.join("README.md"), "原文\n").expect("write markdown");
        let root_uri = format!("file://{}", root.display());

        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.doc.markdown_edit",
                            "workspaceId": "local-ws-user-1",
                            "documentId": "local-md:README.md",
                            "sourceKind": "local_folder",
                            "rootUri": root_uri,
                            "actorId": "user_1",
                            "sessionId": "sess_shared_read_md_audit",
                            "runId": "run_shared_read_md_audit",
                            "toolCallId": "call_shared_read_md_audit",
                            "traceId": "trace_shared_read_md_audit",
                            "idempotencyKey": "idem_shared_read_md_audit",
                            "dryRun": false,
                            "args": {
                                "aiAccessScope": {
                                    "permissionLevel": "shared_read",
                                    "shareContext": {"shareId": "share_read_1"}
                                },
                                "operations": [{"search": "原文", "replace": "不应写入"}]
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::FORBIDDEN);
        let jsonl = fs::read_to_string(audit_dir.join("agent-audit.jsonl")).expect("audit jsonl");
        let event = jsonl
            .lines()
            .filter(|line| !line.trim().is_empty())
            .filter_map(|line| serde_json::from_str::<Value>(line).ok())
            .find(|event| event["runId"] == "run_shared_read_md_audit")
            .expect("audit event");
        assert_eq!(event["runId"], "run_shared_read_md_audit");
        assert_eq!(event["toolName"], "mnote.doc.markdown_edit");
        assert_eq!(event["toolCallId"], "call_shared_read_md_audit");
        assert_eq!(event["status"], "read_only_write_rejected");
        assert_eq!(event["writeAttemptRejected"], true);
        assert_eq!(
            event["rejection"]["code"],
            "mnote_tool_shared_read_write_forbidden"
        );
        assert!(event["changedFiles"].as_array().unwrap().is_empty());
        assert_eq!(
            fs::read_to_string(root.join("README.md")).expect("read"),
            "原文\n"
        );

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

    #[tokio::test]
    async fn hermes_tools_markdown_edit_reports_empty_block_mapping_before_apply() {
        // 7-27 修复后：即使 search 吃掉了块注释，也不会崩溃或泄露 apply_block_ops 错误。
        // 新行为：search 命中后标记为 applied，块文本变为纯文本「测试123」（无块注释），
        // parse_final_markdown_to_blocks 将其识别为新块，原 p_2 块保留。
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.doc.markdown_edit",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_1",
                            "traceId": "trace_1",
                            "idempotencyKey": "idem_markdown_mapping_empty_2",
                            "dryRun": false,
                            "args": {
                                "operations": [{"search": "第二段 <!-- block:p_2 -->", "replace": "测试123"}]
                            }
                        })
                        .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["result"]["operationsApplied"], 1);
        // 确认没有泄露 apply_block_ops 错误
        let text = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(!text.contains("mnote.doc.apply_block_ops operations 不能为空"));
    }

    #[tokio::test]
    async fn hermes_tools_markdown_edit_online_full_content_rejects_unsafe_block_mapping() {
        // 7-27 修复后：full_content 不再被拒绝。新行为：纯文本（无块注释）全部识别为新块，
        // 写入后与原有复杂块（如有）合并保留。
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.doc.markdown_edit",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_1",
                            "traceId": "trace_1",
                            "idempotencyKey": "idem_markdown_full_content_online_2",
                            "dryRun": false,
                            "args": {
                                "full_content": "章节一\n\n第一段已改\n\n第二段已改"
                            }
                        })
                        .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["result"]["operationsApplied"], 1);
        // 确认没有泄露旧错误
        let text = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(!text.contains("mnote_doc_markdown_edit_block_mapping_empty"));
        assert!(!text.contains("mnote.doc.apply_block_ops operations 不能为空"));
    }

    #[tokio::test]
    async fn hermes_tools_markdown_edit_maps_normalized_search_to_block() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.doc.markdown_edit",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_1",
                            "traceId": "trace_1",
                            "idempotencyKey": "idem_markdown_normalized_1",
                            "dryRun": false,
                            "args": {
                                "operations": [{"search": "第二 段", "replace": "测试123"}]
                            }
                        })
                        .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["result"]["operationsApplied"], 1);
        // 7-27: 新路径 changedBlocks 格式验证
        let changed = payload["result"]["applyResult"]["changedBlocks"]
            .as_array()
            .expect("changedBlocks");
        assert!(!changed.is_empty(), "changedBlocks should not be empty");
        assert_eq!(
            payload["result"]["applyResult"]["changedBlocks"][0]["blockId"],
            "p_2"
        );
        assert_eq!(
            payload["result"]["applyResult"]["changedBlocks"][0]["op"],
            "replace"
        );
    }

    #[tokio::test]
    async fn hermes_tools_markdown_edit_online_page_body_save_carries_revision_conflict_key() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.doc.markdown_edit",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_markdown_precondition",
                            "runId": "run_markdown_precondition",
                            "toolCallId": "call_markdown_precondition",
                            "traceId": "trace_markdown_precondition",
                            "idempotencyKey": "idem_markdown_precondition",
                            "dryRun": false,
                            "args": {
                                "operations": [{"search": "第二段", "replace": "测试123"}]
                            }
                        })
                        .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");
        let command_payload =
            &payload["result"]["applyResult"]["artifacts"]["commandLog"]["payload"];
        assert_eq!(command_payload["revision"], 7);
        assert_eq!(command_payload["conflictDetectionKey"], "doc_1:7");
    }

    #[tokio::test]
    async fn hermes_tools_markdown_edit_local_folder_writes_same_markdown_file() {
        let root = std::env::temp_dir().join(format!(
            "mnote-markdown-edit-local-folder-{}",
            std::process::id()
        ));
        let _ = fs::remove_dir_all(&root);
        fs::create_dir_all(root.join("README.assets")).expect("create asset dir");
        fs::write(
            root.join("README.md"),
            "---\ntitle: AI Local\n---\n# AI Local\n\n第一段\n\n![图](README.assets/photo.png)\n",
        )
        .expect("write markdown");
        fs::write(root.join("README.assets").join("photo.png"), b"png").expect("write asset");
        let root_uri = format!("file://{}", root.display());
        crate::routes::local_folder_source::initialize_local_workspace_for_actor(
            "user_1", &root_uri,
        )
        .expect("initialize workspace");

        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.doc.markdown_edit",
                            "workspaceId": "local-ws-test",
                            "documentId": "local-md:README.md",
                            "sourceKind": "local_folder",
                            "rootUri": root_uri,
                            "actorId": "user_1",
                            "sessionId": "sess_local_folder_md",
                            "runId": "run_local_folder_md",
                            "toolCallId": "call_local_folder_md",
                            "traceId": "trace_local_folder_md",
                            "idempotencyKey": "idem_local_folder_md",
                            "dryRun": false,
                            "args": {
                                "operations": [{"search": "第一段", "replace": "第一段已由 AI 修改"}]
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");

        let status = response.status();
        let headers = response.headers().clone();
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let text = String::from_utf8(body.to_vec()).expect("utf8");
        if status != StatusCode::OK {
            panic!("status={status} headers={headers:?} body={text}");
        }
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["result"]["source"], "local_folder");
        assert_eq!(
            payload["result"]["applyResult"]["commandName"],
            "page.body.write"
        );
        let saved = fs::read_to_string(root.join("README.md")).expect("read markdown");
        assert!(saved.contains("第一段已由 AI 修改"));
        assert!(saved.contains("README.assets/photo.png"));
        assert!(!saved.contains("/api/media"));
        assert!(!saved.contains("assetId"));
        let _ = fs::remove_dir_all(&root);
    }

    #[tokio::test]
    async fn hermes_tools_markdown_edit_rejects_no_applied_operations() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.doc.markdown_edit",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_markdown_noop",
                            "runId": "run_markdown_noop",
                            "toolCallId": "call_markdown_noop",
                            "traceId": "trace_markdown_noop",
                            "idempotencyKey": "idem_markdown_noop",
                            "dryRun": false,
                            "args": {
                                "operations": [{"search": "不存在的段落", "replace": "测试123"}]
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
        assert_eq!(
            response
                .headers()
                .get("x-error-code")
                .and_then(|value| value.to_str().ok()),
            Some("mnote_markdown_edit_no_operations_applied")
        );
    }

    #[tokio::test]
    async fn hermes_tools_markdown_edit_rejects_selection_out_of_scope() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.doc.markdown_edit",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_markdown_scope",
                            "runId": "run_markdown_scope",
                            "toolCallId": "call_markdown_scope",
                            "traceId": "trace_markdown_scope",
                            "idempotencyKey": "idem_markdown_scope",
                            "dryRun": true,
                            "args": {
                                "allowedTargetBlockIds": ["p_2"],
                                "operations": [{"search": "第一段", "replace": "不应越权修改"}]
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
        assert_eq!(
            response
                .headers()
                .get("x-error-code")
                .and_then(|value| value.to_str().ok()),
            Some("mnote_markdown_edit_target_out_of_scope")
        );
    }

    #[tokio::test]
    async fn hermes_tools_markdown_edit_merges_same_block_operations() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.doc.markdown_edit",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_1",
                            "traceId": "trace_1",
                            "idempotencyKey": "idem_markdown_same_block_1",
                            "dryRun": true,
                            "args": {
                                "operations": [
                                    {"search": "二", "replace": "2"},
                                    {"search": "2段", "replace": "2段落"}
                                ]
                            }
                        })
                        .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["result"]["operationsApplied"], 2);
        let diff = payload["result"]["applyResult"]["diff"]
            .as_array()
            .expect("diff");
        assert_eq!(diff.len(), 1);
        assert_eq!(diff[0]["blockId"], "p_2");
        // dryRun 时文本不落盘，但 diff 记录预期修改后的内容
        assert!(diff[0]["content"].as_str().unwrap_or("").contains("2段落"));
    }

    #[tokio::test]
    async fn hermes_tools_update_options_dry_run_filters_unwired_fields() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.page.update_options",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_1",
                            "traceId": "trace_1",
                            "idempotencyKey": "idem_options_1",
                            "dryRun": true,
                            "args": {"options": {"wideLayout": true, "pageFont": "serif"}}
                        })
                        .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");
        let options = &payload["result"]["diff"][0]["payload"]["options"];
        assert_eq!(options["wideLayout"], true);
        assert!(options.get("pageFont").is_none());
        assert_eq!(payload["result"]["ignoredOptions"][0], "pageFont");
        assert_eq!(
            payload["result"]["warnings"][0]["code"],
            "page_option_not_wired"
        );
    }

    #[tokio::test]
    async fn hermes_tools_artifact_dry_run_returns_artifact_plan() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.artifact.create_summary",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_1",
                            "traceId": "trace_1",
                            "idempotencyKey": "idem_summary_1",
                            "dryRun": true,
                            "args": {"summary": "摘要内容"}
                        })
                        .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["result"]["dryRun"], true);
        assert_eq!(payload["result"]["artifactType"], "summary");
    }

    #[tokio::test]
    async fn hermes_tools_artifact_summary_local_folder_writes_sidecar_file() {
        let root = std::env::temp_dir().join(format!(
            "mnote-artifact-local-folder-{}",
            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"]}"#,
        )
        .expect("manifest");
        fs::write(root.join("README.md"), "# Local\n").expect("markdown");
        let root_uri = format!("file://{}", root.display());

        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.artifact.create_summary",
                            "workspaceId": "local-ws-user-1",
                            "documentId": "local-md:README.md",
                            "sourceKind": "local_folder",
                            "rootUri": root_uri,
                            "sessionId": "sess_artifact_local",
                            "runId": "run_artifact_local",
                            "toolCallId": "call_artifact_local",
                            "traceId": "trace_artifact_local",
                            "idempotencyKey": "idem_artifact_local",
                            "dryRun": false,
                            "args": {"summary": "本地摘要"}
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");

        let status = response.status();
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let text = String::from_utf8(body.to_vec()).expect("utf8");
        assert_eq!(status, StatusCode::OK, "{text}");
        let payload: Value = serde_json::from_str(&text).expect("json");
        assert_eq!(payload["result"]["source"], "local_folder");
        let artifact_path = root
            .join(".mnote")
            .join("artifacts")
            .join("summary_local-md_README.md.json");
        let artifact = fs::read_to_string(&artifact_path).expect("artifact");
        assert!(artifact.contains("本地摘要"), "{artifact}");
        let _ = fs::remove_dir_all(&root);
    }
}
